Skills
A skill is a reusable, versioned bundle of instructions (and optional supporting files) that you upload to the server once and then attach to a request by reference. Skills follow the open Agent Skills specification: a single top-level folder containing a required SKILL.md manifest (YAML front matter + Markdown instructions), plus optional scripts/, references/, and assets/ files.
You manage skills through the same clients you already use — the PydanticAI, LangGraph, or plain HTTP — and reference a stored skill from a request by adding it to a hosted shell tool’s environment.skills[].
Skills are scoped to the caller (user_id, and the optional x-application-id tenant): by default you only see and reference skills you own. Skills can additionally be shared with other users, an organization, a tenant, or everyone through the /share API.
How It Works
-
You upload a skill bundle to
/v1/skills. The server parses and validates it against the Agent Skills rules, stores it, and returns askill_idatversion1. -
On a
POST /v1/responsesrequest you add ashelltool whoseenvironment.skills[]carries askill_referencepointing at thatskill_id. -
The server validates the reference (owned + exists) and surfaces the skill to the model. When the model decides a skill applies, it invokes the hosted shell tool server-side; the middleware reads the referenced skill’s full content — its
SKILL.mdbody plus every other bundled file (e.g.references/doc.md) — and feeds it back to the model. -
The response carries a
shell_calloutput item (the invocation) and ashell_call_outputitem (the result), and the model’s answer follows the skill’s instructions.
The middleware stores and references skills — it does not execute arbitrary code. A skill_reference guarantees the skill exists and is owned by the caller before the turn proceeds; the referenced skill’s content — its SKILL.md instructions and any supporting files — is what reaches the model.
Managing Skills
Skills are managed through the /v1/skills surface. All endpoints take a Bearer token; an optional x-application-id header scopes the skill to a tenant.
| Method & Path | Purpose |
|---|---|
|
Create a skill from a multipart bundle (→ |
|
List skills you can see (metadata). Optional |
|
Retrieve the default version’s content: |
|
Set the |
|
Replace the default version’s content in place (same multipart upload as |
|
Delete a skill and cascade to all versions/files. |
|
Upload a new version (→ |
|
List versions, ascending (metadata). |
|
Retrieve that version’s content: |
|
Delete a version. |
|
Synthesize a brand-new skill from an existing |
Uploading a bundle
Uploads are multipart/form-data. Each files[] part’s filename carries its path inside a single top-level folder (e.g. echo/SKILL.md, echo/scripts/run.py). Exactly one SKILL.md must sit at the folder root.
|
Zip uploads are not supported. Send the bundle as individual |
# SKILL.md content — YAML front matter followed by Markdown instructions:
# ---
# name: echo
# description: Return the user's exact input back to them verbatim.
# ---
# Start your reply with "Arrr ", then repeat the user's input verbatim.
curl -X POST "$BASE_URL/v1/skills" \
-H "Authorization: Bearer $AA_TOKEN" \
-F "files[]=@SKILL.md;filename=echo/SKILL.md;type=text/markdown"
The manifest (SKILL.md front matter)
| Field | Required | Rules |
|---|---|---|
|
Yes |
1–64 chars, lowercase |
|
Yes |
1–1024 chars, non-empty. |
|
No |
Free-form string. |
|
No |
1–500 chars. |
|
No |
Map of string keys to string values. |
|
No |
Space-separated string (maps to |
Unknown front-matter keys are rejected. Any structural or limit violation returns 400 Bad Request.
Retrieving a skill’s content
GET /v1/skills/{skill_id} returns the content of the skill’s default_version; GET /v1/skills/{skill_id}/versions/{version} returns a specific version’s content. Both share the same two representations, selected by the full_content query flag:
-
Default (
full_contentomitted orfalse) — the version’sSKILL.mdastext/markdown. -
full_content=true— every file of that version packaged as anapplication/ziparchive (served withContent-Disposition: attachment; filename="{skill_id}.zip"), each entry stored at its skill-relative path (e.g.SKILL.md,scripts/run.py,references/doc.md).
# The SKILL.md text of the default version:
curl "$BASE_URL/v1/skills/skill_9f2c..." \
-H "Authorization: Bearer $AA_TOKEN"
# Every file of the default version, as a zip:
curl "$BASE_URL/v1/skills/skill_9f2c...?full_content=true" \
-H "Authorization: Bearer $AA_TOKEN" -o skill.zip
These endpoints return content, not metadata. For the Skill / SkillVersion metadata shapes (ids, version pointers, manifests), use GET /v1/skills and GET /v1/skills/{skill_id}/versions.
Versioning
A skill owns one or more versions. Uploading to POST /v1/skills/{skill_id}/versions assigns version = latest_version + 1 and advances latest_version, but leaves default_version untouched — you promote a version explicitly with POST /v1/skills/{skill_id} ({"default_version": N}). Each SkillVersion listed by GET /v1/skills/{skill_id}/versions carries that version’s own manifest:
{
"skill_id": "skill_9f2c...",
"version": 2,
"created_at": 1767312000,
"file_count": 3,
"size_bytes": 20480,
"manifest": {
"name": "echo",
"description": "Return the user's exact input back to them verbatim.",
"license": null,
"compatibility": null,
"metadata": {},
"allowed_tools": null
}
}
Delete rules (DELETE /v1/skills/{skill_id}/versions/{version}):
-
Deleting the current
default_versionis rejected with409 Conflict— set another default first. -
Deleting the last remaining version deletes the whole skill (
204 No Content). -
Otherwise the version is removed,
latest_versionis recomputed, and the updatedSkillis returned (200 OK).
Replacing the default version’s content
PATCH /v1/skills/{skill_id} is structurally identical to POST /v1/skills — the same multipart files[] upload (a directory of parts or a single .zip) — but instead of creating a new skill or version it overwrites the current default version in place. Both the manifest (the SKILL.md front matter — name/description/metadata) and the stored file bytes are replaced, and the skill’s denormalized name/description are refreshed. It does not create a new version: default_version and latest_version are unchanged. To add a new version instead, use POST /v1/skills/{skill_id}/versions.
curl -X PATCH "$BASE_URL/v1/skills/skill_9f2c..." \
-H "Authorization: Bearer $AA_TOKEN" \
-F "files[]=@echo/SKILL.md;filename=echo/SKILL.md;type=text/markdown"
Synthesizing a skill from a response
POST /v1/skill-create builds a new skill from an existing response instead of an upload. You pass a response_id; the server reconstructs that conversation’s full context, asks the backend (with a fixed, server-owned authoring prompt) to distill it into a SKILL.md, validates the result, and stores it exactly like POST /v1/skills.
curl -X POST "$BASE_URL/v1/skill-create" \
-H "Authorization: Bearer $AA_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "response_id": "resp_..." }'
Returns the created Skill (same shape as POST /v1/skills). The response_id must be visible to the caller, or the request fails with 404.
Sharing Skills
By default a skill is private to its owner. The /share API grants other subjects access to a skill you own.
/share is generic over the object being shared; skills are the skill component, so every path below is /share/skill. Every /share request is audited (identifiers and outcome only — never content).
Who may share: the can_share gate
Every /share operation is gated by the can_share permission before anything else happens. can_share is held by:
-
the skill’s owner, and
-
a tenant admin (an admin’s
can_adminsubsumescan_share).
A caller without can_share gets 404 Not Found (resource_not_found) — not 403 — so a skill’s existence is never leaked to someone who may not administer it.
Roles
Only two roles are shareable; owner and pharia-ai-admin are never shareable (sharing them would be privilege escalation):
| Role | Grants |
|---|---|
|
Read the skill (view metadata, list/read versions, reference it in a request). |
|
Everything a viewer can, plus edit the manifest and upload new versions. |
Subjects and the escalation ladder
A grant names one or more concrete subjects. How broadly you may share is capped by your own role (the escalation ladder):
| Subject (request field) | Meaning | Who may grant it |
|---|---|---|
|
Named user ids. |
Owner (users in their own organization/tenant only) or admin (any user). |
|
Every member of an organization. |
Owner (their own organization only) or admin (any organization). |
|
Org-public: every member of the caller’s (or named) organization. |
Owner (their own organization only) or admin. |
|
Tenant-public: every member of the tenant. |
Tenant admin only. |
|
Public to all users (the |
Tenant admin only. |
A grant that exceeds the caller’s breadth (e.g. a non-admin owner asking for tenant/everyone, or targeting another organization or an out-of-org user) is rejected with 403 Forbidden (share_scope_forbidden) — the caller already knows the skill exists, so 403 (not 404) is used here.
| currently, only public sharing (i.e. "public_scope: everyone") is supported by the API. |
POST /share/skill — grant (or widen) access
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
|
string |
Yes |
The |
|
|
Yes |
The role to grant. |
|
list of string |
No |
User ids to grant to. |
|
string |
No |
An organization id to grant to. |
|
|
No |
A public userset to grant to. |
At least one concrete subject (users, organization, or public_scope) must be named, otherwise the request is rejected with 400 Bad Request (share_request_invalid). A grant only ever widens access — use DELETE to remove a rule.
# Grant a colleague viewer access:
curl -X POST "$BASE_URL/share/skill" \
-H "Authorization: Bearer $AA_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "object_id": "skill_9f2c...", "permission": "viewer", "users": ["user_b"] }'
# Make it editable by everyone in your organization:
curl -X POST "$BASE_URL/share/skill" \
-H "Authorization: Bearer $AA_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "object_id": "skill_9f2c...", "permission": "editor", "public_scope": "org" }'
# Tenant admin only — make it viewable by everyone:
curl -X POST "$BASE_URL/share/skill" \
-H "Authorization: Bearer $AA_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "object_id": "skill_9f2c...", "permission": "viewer", "public_scope": "everyone" }'
A successful grant returns 200 OK with { "status": "granted" }.
GET /share/skill — list shared skills, or describe one skill’s ACL
Two modes, selected by the optional object_id query parameter:
-
Without
object_id— every skill visible to the caller (owned plus shared):curl "$BASE_URL/share/skill" \ -H "Authorization: Bearer $AA_TOKEN"{ "object_ids": ["skill_9f2c...", "skill_1a7d..."] } -
With
?object_id=<skill_id>— the full access-control list of that skill. Gated bycan_share(only the owner or a tenant admin can inspect who a skill is shared with):curl "$BASE_URL/share/skill?object_id=skill_9f2c..." \ -H "Authorization: Bearer $AA_TOKEN"{ "component": "skill", "object_id": "skill_9f2c...", "owner": "user_a", "viewers": ["user_b"], "editors": [], "organizations": [{ "id": "org_acme", "permission": "editor" }], "tenants": [], "everyone": ["viewer"] }
DELETE /share/skill — revoke a share rule
Request body (same subject fields as grant):
| Field | Type | Required | Description |
|---|---|---|---|
|
string |
Yes |
The |
|
|
No |
The role to remove. Omit to remove both |
|
list of string |
No |
User ids to revoke from. |
|
string |
No |
Organization id to revoke from. |
|
|
No |
Public userset to revoke. |
The delete is always scoped to the one skill and the one subject/set named. A request that names no concrete subject is rejected with 400 Bad Request (share_request_invalid) rather than falling through to a broad "delete everything" — an under-specified revoke never removes more than intended.
# Revoke a colleague's viewer grant:
curl -X DELETE "$BASE_URL/share/skill" \
-H "Authorization: Bearer $AA_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "object_id": "skill_9f2c...", "permission": "viewer", "users": ["user_b"] }'
# Remove all of an organization's access (both roles):
curl -X DELETE "$BASE_URL/share/skill" \
-H "Authorization: Bearer $AA_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "object_id": "skill_9f2c...", "organization": "org_acme" }'
A successful revoke returns 200 OK with { "status": "revoked" }.
Share error reference
| Status | code |
When |
|---|---|---|
|
|
Caller lacks |
|
|
Caller may share, but not this broadly (escalation). |
|
|
Missing |
Referencing a Skill in a Request
To make a stored skill available on a turn, add a shell tool and list the skill under environment.skills[] as a skill_reference:
{
"type": "shell",
"environment": {
"type": "container_auto",
"skills": [
{ "type": "skill_reference", "skill_id": "skill_9f2c..." }
]
}
}
Reference fields:
| Field | Type | Description |
|---|---|---|
|
|
Required. |
|
string |
Required. The stored skill’s id. |
|
int |
Optional. Omit to use the skill’s |
Notes:
-
The
skill_idmust exist and be visible to the caller — otherwise the request fails with400 Bad Request. -
Multiple
shelltools (and multiple skills) are aggregated and de-duplicated byskill_idfor the turn. -
The model decides whether a skill applies; the tool only makes it available.
-
curl
-
Python (PydanticAI)
-
Python (LangGraph)
curl -X POST "$BASE_URL/v1/responses" \
-H "Authorization: Bearer $AA_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"model": "qwen3-32b-tool",
"input": "Apply the echo skill to: PINEAPPLE-42",
"instructions": "You must use the echo skill to answer.",
"tools": [
{
"type": "shell",
"environment": {
"type": "container_auto",
"skills": [
{ "type": "skill_reference", "skill_id": "skill_9f2c..." }
]
}
}
]
}'
Hosted tools are server-executed, so pass the shell tool through model_settings:
from pydantic_ai import Agent
from pydantic_ai.models.openai import OpenAIResponsesModel
from pydantic_ai.models.openai import OpenAIResponsesModelSettings
shell_tool = {
"type": "shell",
"environment": {
"type": "container_auto",
"skills": [{"type": "skill_reference", "skill_id": skill_id}],
},
}
agent = Agent(
model=OpenAIResponsesModel("qwen3-32b-tool", provider=provider),
system_prompt="You must use the echo skill to answer.",
model_settings=OpenAIResponsesModelSettings(
openai_native_tools=[shell_tool],
),
)
result = await agent.run("Apply the echo skill to: PINEAPPLE-42")
print(result.output)
Native Responses API tool types are passed through unchanged via bind_tools:
from langchain_core.messages import HumanMessage, SystemMessage
shell_tool = {
"type": "shell",
"environment": {
"type": "container_auto",
"skills": [{"type": "skill_reference", "skill_id": skill_id}],
},
}
llm_with_shell = llm.bind_tools([shell_tool])
response = llm_with_shell.invoke([
SystemMessage("You must use the echo skill to answer."),
HumanMessage("Apply the echo skill to: PINEAPPLE-42"),
])
print(response.text)
The Response
When the model uses a referenced skill, the hosted shell execution appends two items to the response output:
| Output Type | Description |
|---|---|
|
The tool invocation — the model asking to read/apply a skill. |
|
The result fed back to the model — the skill’s full content: its |
|
The model’s final answer, produced after following the skill. |
Streaming events
For a streamed turn, the shell_call lifecycle surfaces through the standard output-item events:
-
response.output_item.added— ashell_callitem,status: "in_progress". -
response.output_item.done— the same item,status: "completed". -
response.output_text.delta… — the answer text. -
response.completed, then[DONE].
The final response.completed snapshot carries the full output list, including the shell_call_output item. On reload, the persisted response returns the same shell_call / shell_call_output items — no need to replay the stream.
Configuration
| Variable | Default | Description |
|---|---|---|
|
|
Where skill file bytes are stored: |
|
— |
Data-platform (Sherlock) base URL. Reused as the endpoint for the |
|
|
Maximum size of an uploaded bundle. |
|
|
Maximum number of files in a single version. |
|
|
Maximum uncompressed size of any single file. |
|
|
Maximum total uncompressed size across a bundle (zip-bomb guard). |
The hosted shell tool executes inside the agentic loop, so skill references are only surfaced to the model when the agentic loop is active on the deployment. Skill metadata uses the same memory / postgres backend selection as conversations; skill file bytes follow SKILL_DATA_BACKEND.