Skills are reusable prompt templates stored in Truememory — short, named instructions like “summarize this thread” or “translate to formal English” that an app can pull up by key and run on demand. Truememory stores the template; the calling app decides when to invoke it and how to render it (a slash command, a button in the UI, a quick action menu, etc.).
If your app already builds prompt presets on its own backend, skills move that storage into Truememory so the same set is available everywhere the user goes — BlinkLife, Vibrantly, and any future app on the platform — without each app maintaining its own copy.
These endpoints manage app-facing skill records in the server database. They are separate from the repository’s .agents/skills or prompt-instruction folders, which are development-time instructions for LLM agents working on this codebase.
All skill endpoints require authentication. User-scoped endpoints accept either:
- a user Bearer token from Truememory Auth
- the calling app’s service token plus
X-On-Behalf-Of: <user_id>when your backend is proxying the request for a user
Admin endpoints require the service token (see Admin endpoints below).
Integration flow
Use skills in two steps:
- Store or fetch the skill template through
/skills. - Invoke your agent by sending the skill’s
promptthrough/v1/chat/completions, usually inskill_instructionsor as part of your caller-composed prompt.
For example, a slash-command menu can load GET /skills, render each skill by name, icon, and argument_hint, then fetch the selected row by key before sending its prompt into the chat request.
# Backend proxying for a user
GET https://your-tie-host/skills?is_active=true
Authorization: Bearer $AUTH_SECRET
X-On-Behalf-Of: 11111111-2222-3333-4444-555555555555# Later, invoke the selected template
POST https://your-tie-host/v1/chat/completions
Authorization: Bearer $AUTH_SECRET
X-On-Behalf-Of: 11111111-2222-3333-4444-555555555555
Content-Type: application/json
{
"model": "anthropic/claude-sonnet-4-5",
"messages": [
{ "role": "user", "content": "Summarize this thread for my product standup." }
],
"skill_instructions": "Summarize the conversation so far in 3 short bullet points.",
"metadata": {
"active_skill": "summarize-thread"
}
}Truememory does not automatically inject a skill’s prompt into chat completion requests. Your app decides which skill applies, reads the stored prompt, and passes it via skill_instructions. There is one automatic connection: passing the skill’s id as skill_id on a chat request retrieves every knowledge pack tagged with that skill.
What a skill actually is
A skill is a row of caller-defined data. Truememory does not interpret the prompt text, the allowed_tools list, the category, or the metadata blob — it stores and returns them. Your app decides what to do with each field when it invokes the skill.
This matters because:
- A skill named
summarizedoes not automatically run a summarization — your app reads the skill’spromptand sends it through/v1/chat/completions(or wherever it routes prompts). allowed_toolsis metadata, not enforcement. Truememory will not block tool calls based on this list; the caller is responsible for honoring it at invocation time.iconandargument_hintexist to help your UI render the skill — Truememory never reads them.categoryis the one exception: Truememory accepts it as an exact-match filter onGET /skills?category=….metadatais a caller-owned JSON object. Use it for integration hints your app understands, not for Truememory control flags. Anything Truememory acts on is a real field instead — seeuse_caseanddetection_patternsbelow, which were promoted out ofmetadatafor exactly this reason.
Trigger fields
Two fields describe when a skill applies, as opposed to what it does. Both are optional — a skill without them is still invokable by key, it just never gets selected automatically.
| Field | Type | Purpose |
|---|---|---|
use_case |
string | When the skill applies, in natural language: "Triggers when a user shares a photo of food, a menu, or ingredients." |
detection_patterns |
string[] | Example utterances that should invoke it: ["what are the macros in this", "can I eat these berries"] |
Write use_case to describe the trigger, not the behaviour — “Triggers when the user asks for a recap” beats “Summarizes conversations.”
detection_patterns are matched semantically, not literally. List distinct phrasings rather than keyword variants: “can I eat these berries” also covers “are these mushrooms safe”, so a separate entry for every synonym is wasted effort. Up to 32 entries, 500 characters each.
Both were previously written into metadata. That was the right place while Truememory only stored them; once selection reads them they are control flags, which metadata explicitly is not for. Existing values were copied into the new columns by migration 037, and reads still fall back to metadata so a client that has not switched keeps working. Send them as real fields going forward.
Keys that remain caller-owned — platform_skill_type, icon_data, model_tier — should stay in metadata. Truememory does not act on them.
Automatic selection
Send skill_selection: "auto" on a chat completion and Truememory matches the user’s message against the trigger data of every skill visible to that caller and app, injecting the closest matches:
{
"model": "anthropic/claude-sonnet-4-5",
"messages": [{ "role": "user", "content": "what are the macros in this" }],
"skill_selection": "auto"
}Defaults to "off", so nothing changes until you ask for it. It combines with skills: explicitly named keys always apply, and matches are appended after them without duplicating one you already named.
Only skills you could already list are candidates, and only those carrying trigger data — a skill with no use_case or detection_patterns is never selected automatically, though it stays invokable by key. At most 3 skills are selected per turn.
Matching is semantic, so paraphrases work: “how many calories in this” selects a skill whose patterns are “what are the macros in this” and “scan this nutrition label”, sharing no words with either. Messages carrying little information (“hi”, “thanks”) skip matching entirely.
Selection is best-effort. If matching cannot run, the turn proceeds with no automatically selected skills rather than failing — unlike a key you named in skills, which 404s when it cannot be resolved.
Think of it like: a shared snippet library. The library stores the snippet text; the editor that opens it decides how to expand and run it.
Where that metaphor breaks down: unlike most snippet libraries, skills have ownership scoping (presets vs. custom) and a fork relationship built in.
Presets and custom skills
Every skill is one of two kinds:
| Kind | user_id |
is_preset |
Who can see it | Who can mutate it |
|---|---|---|---|---|
| Preset | null |
true |
Every authenticated user | Admins only (service token) |
| Custom | the owner | false |
Only the owner | Only the owner |
When a user owns a custom skill whose key matches a preset’s key, the user’s version wins on GET /skills/by-key/{key}. This is intentional — it lets users override a preset without losing the preset for everyone else.
Key naming
key is the stable identifier your app uses to find the skill (typically the slash command name, like /summarize). It must:
- Be 2–64 characters
- Use only lowercase letters, digits, hyphens, or underscores
- Start and end with a letter or digit
Two different users can each own a skill with the same key (for example, both have a summarize). A single user cannot have two skills with the same key, and presets share one global keyspace.
App scoping
Every skill carries an app_ids array that controls which apps can see it. One skill can serve several apps, or every app on the platform.
app_ids value |
Who sees the skill |
|---|---|
null (or omitted) |
Every app — the skill is global |
["vibrantly", "blinklife"] |
Only those apps, plus nothing else |
Pass app_ids on create or update:
POST https://your-tie-host/skills
Authorization: Bearer $TOKEN
Content-Type: application/json
{
"key": "summarize-thread",
"name": "Summarize Thread",
"prompt": "Summarize the conversation so far in 3 short bullet points.",
"app_ids": ["vibrantly", "blinklife"]
}Send "app_ids": null to make the skill global. An empty list is treated the same as null.
Legacy app_id. The earlier scalar app_id field still works on create and update. Truememory folds a single app_id into a one-element app_ids list, so existing single-app callers need no changes. Responses always return app_ids.
Reading by app. The app_id query parameter on GET /skills names the requesting app, not the skill. It is still a single value:
GET https://your-tie-host/skills?app_id=vibrantly
Authorization: Bearer $TOKENA user-facing read returns skills assigned to that app plus global skills (app_ids of null). Admin reads (GET /admin/skills?app_id=…) match the app exactly and do not include global skills. Omit the parameter to read across every app.
This app_ids array is one of two ways to scope a read. The other is the admin-managed app/category taxonomy, reached with the X-Use-Taxonomy header — see Listing through the app/category taxonomy.
List skills
Returns presets plus the authenticated user’s custom skills, paginated. Results are ordered presets first, then by sort_order ascending, then by name.
GET https://your-tie-host/skills?category=writing&is_active=true&search=summary&limit=50&offset=0
Authorization: Bearer $TOKEN| Query param | Type | Default | Description |
|---|---|---|---|
app_id |
string | — | Requesting app. Returns skills assigned to it plus global skills. Omit to read across every app. See App scoping |
category |
string | — | Exact match on the category field |
is_active |
bool | — | Filter by active flag. Inactive skills are still listed when this is omitted |
search |
string | — | Case-insensitive substring match on name or description |
limit |
int | 50 | 1–200 |
offset |
int | 0 | — |
For backend-to-backend integration, use the service token and pass the user subject explicitly:
GET https://your-tie-host/skills?is_active=true
Authorization: Bearer $AUTH_SECRET
X-On-Behalf-Of: 11111111-2222-3333-4444-555555555555Response:
{
"skills": [
{
"id": "0bc0...",
"user_id": null,
"app_ids": null,
"key": "summarize",
"name": "Summarize Thread",
"description": "Summarize the current conversation in three bullets.",
"prompt": "Summarize the conversation so far in 3 short bullet points.",
"category": "productivity",
"icon": "sparkles",
"argument_hint": null,
"allowed_tools": [],
"metadata": {},
"is_preset": true,
"is_active": true,
"sort_order": 0,
"forked_from_skill_id": null,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}
],
"total": 1
}total is the count of all matching rows before limit/offset — use it to paginate.
Listing through the app/category taxonomy
Alongside the app_ids array and the free-text category field, Truememory keeps an admin-managed app/category taxonomy: apps and categories are first-class records — each app has a unique, URL-safe slug — and skills are grouped into categories that are grouped under apps.
To list skills through that taxonomy, send the X-Use-Taxonomy: true header on the same GET /skills endpoint. The header does not add any new endpoint — it only changes how two existing query parameters are interpreted:
GET https://your-tie-host/skills?app_id=vibrantly&category=6f1c8b2a-...&is_active=true
Authorization: Bearer $TOKEN
X-Use-Taxonomy: true| Parameter | Without the header (default) | With X-Use-Taxonomy: true |
|---|---|---|
app_id |
The app_ids scope value (see App scoping) |
An app slug, resolved through the taxonomy |
category |
Exact match on the free-text category field |
A category id (UUID) from the taxonomy |
is_active, search, limit, offset, the ordering, and the response shape are all unchanged.
Two behaviours to note in taxonomy mode:
- Unknown slug 404s. If
app_idis a slug no app owns, the request returns404 APP_NOT_FOUND— a typo fails loudly instead of returning an empty list. A valid app that simply has no linked skills returns an emptyskillsarray withtotal: 0. - No global skills. Unlike the default read, taxonomy mode returns only the skills the taxonomy links to that app; it does not add global (
app_ids: null) skills.
Get a skill
Two ways to read a single skill: by id, or by key.
GET https://your-tie-host/skills/{skill_id}
Authorization: Bearer $TOKENReturns 404 if the skill does not exist or is owned by another user.
GET https://your-tie-host/skills/by-key/{key}
Authorization: Bearer $TOKENWhen a user owns a skill with the same key as a preset, the user’s version is returned (override semantics).
Create a custom skill
POST https://your-tie-host/skills
Authorization: Bearer $TOKEN
Content-Type: application/json
{
"key": "summarize-thread",
"name": "Summarize Thread",
"description": "Summarize the current conversation in three bullets.",
"prompt": "Summarize the conversation so far in 3 short bullet points.",
"category": "productivity",
"app_ids": ["vibrantly"],
"icon": "sparkles",
"argument_hint": "[topic]",
"allowed_tools": ["web_search"],
"metadata": {},
"is_active": true,
"sort_order": 0,
"forked_from_skill_id": null
}Returns the created skill with HTTP 201 Created. Only key, name, and prompt are required.
| Field | Type | Notes |
|---|---|---|
key |
string | See Key naming |
app_ids |
string[] | null | Apps that can see the skill. null means every app. See App scoping. The legacy scalar app_id is also accepted |
name |
string | 1–255 chars |
description |
string | null | Optional, no length cap |
prompt |
string | The template body, sent to the LLM by the caller |
category |
string | null | ≤ 100 chars |
icon |
string | null | ≤ 255 chars. Interpreted by the caller — any identifier or URL |
argument_hint |
string | null | ≤ 255 chars. Hint shown to the user about expected arguments (e.g. [topic]) |
allowed_tools |
string[] | Caller-defined list; Truememory stores but does not enforce |
metadata |
object | Free-form JSON owned by the caller |
is_active |
bool | Default true. Inactive skills are still listed |
sort_order |
int | Display order; lower sorts earlier |
forked_from_skill_id |
string | null | Optional — see Forking a preset |
Omitted optional fields use server defaults: description, category, app_ids, icon, argument_hint, and forked_from_skill_id become null (a null app_ids means every app); allowed_tools becomes []; metadata becomes {}; is_active becomes true; sort_order becomes 0.
Errors:
409 SKILL_KEY_TAKEN— the user already owns a skill with this key400 SKILL_FORK_INVALID—forked_from_skill_idis set but does not point at an existing preset422validation error — the request body does not match the schema, such as an invalidkey, emptyprompt, ornullfor a required update field
Forking a preset
Setting forked_from_skill_id on create copies the relationship — the new skill is a user-owned snapshot of the preset, with a back-pointer for analytics or UI hints. Only preset skills are forkable; passing the id of another user’s custom skill, or any non-preset row, is rejected with 400 SKILL_FORK_INVALID.
Fork is metadata, not a live link. Edits to the preset do not propagate to forks.
Update a custom skill
PUT https://your-tie-host/skills/{skill_id}
Authorization: Bearer $TOKEN
Content-Type: application/json
{
"name": "Summarize (concise)",
"prompt": "Summarize the conversation in 2 short bullets, max 12 words each."
}All fields are optional — send only what you want to change. Returns 404 if the skill does not exist, is not owned by the user, or is a preset.
Nullable vs. NOT NULL: description, category, app_ids, icon, argument_hint, and forked_from_skill_id accept explicit null to clear the value (a null app_ids makes the skill global). The remaining fields are NOT NULL — sending null is rejected; omit the field to leave it unchanged.
Delete a custom skill
DELETE https://your-tie-host/skills/{skill_id}
Authorization: Bearer $TOKENResponse:
{ "status": "deleted", "skill_id": "0bc0..." }Returns 404 if the skill does not exist, is not owned by the user, or is a preset.
Admin endpoints
A second route block under /admin/skills/* exposes the same CRUD without ownership or preset scoping. It lets a service create presets, list skills across all users, and mutate or delete any row regardless of is_preset.
These endpoints are gated by the Truememory service token (AUTH_SECRET), not a user Bearer token. The trust model is: the calling app validates that the human acting through it is an admin; Truememory trusts the service token. End-user tokens cannot reach these endpoints.
X-On-Behalf-Of is not needed on admin calls. Admin routes are authorized by the service token, and they do not use a subject user unless you explicitly pass user_id in an admin request body or query string.
POST https://your-tie-host/admin/skills
Authorization: Bearer $AUTH_SECRET
Content-Type: application/json
{
"key": "summarize-thread",
"name": "Summarize Thread",
"prompt": "Summarize the conversation so far in 3 short bullet points.",
"category": "productivity",
"icon": "sparkles"
}Omit user_id or set it to null to create a preset visible to every authenticated user. Set user_id to create a custom row owned by that user.
Admin create and update accept app_ids exactly like the user endpoints (see App scoping), so an admin can scope a preset to specific apps or share it with all of them. The legacy scalar app_id is accepted here too.
| Endpoint | Behavior |
|---|---|
GET /admin/skills |
List across all users. Filters: user_id, app_id (exact match, no global skills), is_preset, category, is_active, search, limit, offset |
POST /admin/skills |
Create. user_id null → preset; user_id set → row owned by that user |
GET /admin/skills/{skill_id} |
Get any skill |
PUT /admin/skills/{skill_id} |
Update any skill, including presets |
DELETE /admin/skills/{skill_id} |
Delete any skill, including presets |
Fork validation (SKILL_FORK_INVALID) still runs on admin paths — it is a data invariant, not a permission check.
Common pitfalls
- Confusing API skills with LLM instruction files. The
/skillsendpoints manage rows in the Truememory database for apps to use at runtime. They do not read or write.agents/skills,.claude/skills, or other repository-local instruction folders. - Treating
allowed_toolsas enforcement. Truememory does not block tool calls based on this list. If your app needs to restrict which tools a skill can use, enforce it in the caller before sending the prompt. - Trying to update or delete a preset via the user endpoints. Returns 404, not 403 — presets are invisible to the mutation path on purpose. Use the admin endpoints if you genuinely need to change a preset.
- Calling admin endpoints with an end-user token. Admin routes require the service token. If your product has human admins, enforce that role in your app before forwarding the admin request to Truememory.
- Re-fetching presets per request to render a slash menu. Skills change rarely; cache the list on the client and invalidate on user-driven create/update/delete.
- Assuming forks track the preset. Fork is a static back-pointer. If the preset changes, existing forks do not.