The key owner's address, not the key's. A key carries a person's identity and its reach follows their membership — so this is how you tell whose access you are actually using. See Authentication.
Appearance
Appearance
Four endpoints, in the order you would normally call them: find out what your key can reach, resolve a project's skill set, fetch one skill, then keep a local copy in step.
GET /skills/whoami | who this key is, and which projects it reaches |
GET /skills/resolve | the effective skill set for a project |
GET /skills/by-name/{name} | one skill, including its body |
GET /skills/diff | compare what you hold against the server |
All four are available on SaaS and on-prem.
Every endpoint on this page accepts the key either way — as an X-API-Key header or as an ?api_key= query parameter:
curl -s "$IM_URL/skills/resolve?project_id=$PID" -H "X-API-Key: $IM_KEY"
curl -s "$IM_URL/skills/resolve?project_id=$PID&api_key=$IM_KEY"Publishing does not accept the query parameter
POST /skills/active-version reads the header only. Carrying the ?api_key= habit over from this page returns 401 API key required, which reads like a bad key rather than a misplaced one. See Publishing.
These endpoints require the key owner to be a member of the project — being in its organisation is not enough. Super Admins count as members of every project in their organisation, resolved per request.
GET /skills/bundle is the exception and authorises on the skill instead. Everything else here follows the rule above.
GET /skills/whoamiTakes no parameters beyond the key. Start here — it answers "which project_id?".
curl -s "$IM_URL/skills/whoami" -H "X-API-Key: $IM_KEY"{
"user_id": "cd5c29a0-7b51-4f1f-8381-1b0c213e7131",
"email": "you@example.com",
"organizations": [
{ "id": "d7fb6107-1188-4f7c-97e2-978403f3e3be", "name": "Optisol" },
{ "id": "d318cf5e-51f3-451e-9c98-3c9bfbce6126", "name": "Demo Organization" }
],
"accessible_projects": 3,
"projects": [
{ "id": "3a83bbe8-6a99-4137-a3d9-7e4dc72e5bfe", "name": "sample project" },
{ "id": "8112d9c4-8334-43dd-a3c0-436b5cf1f475", "name": "sample" },
{ "id": "873e1fe9-c13e-4a51-8727-ba039096e7f5", "name": "sample project" }
],
"key_id": "b2081d28-27d3-4ec3-b70b-a7083e5cf1d5",
"key_created_at": "2026-08-26T12:20:13.052199"
}emailstringThe key owner's address, not the key's. A key carries a person's identity and its reach follows their membership — so this is how you tell whose access you are actually using. See Authentication.
projectsarrayEvery project the owner can reach, with the id you need for the other endpoints. Project names are not unique — two entries above share the name sample project — so key your integration on the id.
GET /skills/resolveThe effective skill set for a project after tier resolution and shadowing — what a consumer in that project would actually get, rather than a union you have to resolve yourself. This is what pm-skills pull uses. See Resolution & pins.
curl -s "$IM_URL/skills/resolve?project_id=$PID&format=index" -H "X-API-Key: $IM_KEY"| Parameter | Required | Notes |
|---|---|---|
project_id | yes | |
format | no | full (the default) or index |
environment | no | echoed back, nothing more — see below |
format defaults to full
Omitting it returns every skill body in the response. Pass format=index when you only need to know what is there.
{
"project_id": "8112d9c4-8334-43dd-a3c0-436b5cf1f475",
"environment": null,
"skills": [
{
"name": "sonar-clean-code",
"version": {
"sha": "aacea0c4cd13",
"skill_id": "5adfded2-758e-48c7-9ed9-25b7a3c3a4f5",
"frontmatter": { "name": "sonar-clean-code", "description": "Enforce SonarQube clean-code rules…" },
"body": "",
"message": "Promoted from project sonar-clean-code",
"status": "active",
"version_label": "v1.0",
"resources": [],
"scan": null,
"published_via": null
},
"resolution": { "tier": "organization", "shadowed": [], "pinned": false }
},
{
"name": "test",
"version": { "sha": "a28b804cda57", "version_label": "v1.0", "published_via": "direct", "…": "…" },
"resolution": { "tier": "project", "shadowed": [], "pinned": false }
}
]
}resolution.tierstringWhich tier won for this project — project, organization or global. Above, sonar-clean-code resolved from the organisation and test from the project itself.
resolution.shadowedarrayWhat this skill hid. A project-tier skill shadows an organisation-tier one of the same name, and each entry names the tier that lost. Empty when nothing was hidden.
resolution.pinnedbooleantrue when the project holds this skill at a fixed sha rather than following its active version.
environmentstring | nullEchoed straight back. Skills are not environment-gated — the parameter exists so a lockfile can record which environment it was generated for.
This is a real difference from prompts, where environment filters and can produce a 409. The parameter looks the same on both and behaves differently.
format=index actually trims index empties body and resources. Everything else — frontmatter (including the full description), message, status, version_label, scan — is still there. It is a lighter response, not a summary one.
GET /skills/by-name/{name}One resolved skill, including its body.
curl -s -X GET \
"$IM_URL/skills/by-name/BusinessAnalystAgent?project_id=$PID&api_key=$IM_KEY" \
-H "Accept: application/json"| Parameter | Required | Notes |
|---|---|---|
name | yes | path segment; exact and case-sensitive |
project_id | yes | resolution happens in this project's context |
version | no | a sha, or latest (the default) |
The response is the version object on its own — the same shape resolve nests under version:
{
"sha": "a28b804cda57",
"skill_id": "a0132e30-e7c4-4b78-8719-d8e20cbe44bb",
"frontmatter": {
"name": "test",
"description": "aad",
"license": null,
"compatibility": null,
"allowed_tools": null,
"metadata": {}
},
"body": "adfasdf",
"message": "hello world",
"status": "active",
"major_version": 1,
"minor_version": 0,
"version_label": "v1.0",
"resources": [],
"created_by_name": "Mohanpathi S",
"created_at": "2026-08-25T12:22:31.096911",
"scan": {
"engine": "skillspector",
"engine_version": "2.2.3",
"verdict": "clean",
"risk_score": 0,
"findings": []
},
"published_via": "direct"
}No name, and no tier, in the response
There is no wrapper — so the reply does not tell you which tier the skill resolved from. If you need that, use resolve, which returns a resolution block per skill.
shastringIdentifies this exact version. Keep it — publishing over this skill requires it as expected_active_sha. See Publishing.
scanobject | nullThe security scan report, or null if this version was never scanned. verdict is the field to read.
published_viastring | nullHow this version reached production: api, direct, or null for the review workflow. See Publishing flows.
version accepts a sha, but only one belonging to the skill that name resolves to. A sha from a different skill is a 404, not someone else's content:
# a28b804cda57 belongs to `test` → 200
curl -s "$IM_URL/skills/by-name/test?project_id=$PID&version=a28b804cda57" -H "X-API-Key: $IM_KEY"
# aacea0c4cd13 belongs to `sonar-clean-code` → 404
curl -s "$IM_URL/skills/by-name/test?project_id=$PID&version=aacea0c4cd13" -H "X-API-Key: $IM_KEY"GET /skills/diffCompare what you hold locally against current resolution — how pm-skills status decides what is stale.
curl -s -G "$IM_URL/skills/diff" \
--data-urlencode "project_id=$PID" \
--data-urlencode 'local={"test":"oldsha123456"}' \
-H "X-API-Key: $IM_KEY"| Parameter | Required | Notes |
|---|---|---|
project_id | yes | |
local | no | JSON object of {name: sha}; defaults to {} |
{
"project_id": "8112d9c4-8334-43dd-a3c0-436b5cf1f475",
"entries": [
{
"name": "sonar-clean-code",
"state": "added",
"local_sha": null,
"server_sha": "aacea0c4cd13",
"tier": "organization"
},
{
"name": "test",
"state": "updated",
"local_sha": "oldsha123456",
"server_sha": "a28b804cda57",
"tier": "project"
}
]
}statestringadded — resolves for the project, you do not have itupdated — you have it at a different sharemoved — you have it, it no longer resolvesSending local={} therefore reports every skill as added, which is the correct answer for an empty working copy.
local must be a JSON object
A JSON array, or anything unparseable, is a 400 — not a 422, and not a silently empty comparison:
{ "detail": "`local` must be a JSON object of {name: sha}" }Remember to URL-encode it. --data-urlencode above does this for you.
Shared by every endpoint on this page.
| Status | When | Notes |
|---|---|---|
400 | diff only — local is not a JSON object | the only 400 on this surface |
401 | key missing, invalid or revoked | |
403 | the owner is not a member of that project | |
404 | the name does not resolve for this project, or the sha belongs to another skill |
A project that does not exist returns 403, not 404
Membership is checked before the project is resolved, and you cannot be a member of a project that is not there. So a typo in project_id reads as "you may not look" rather than "no such project" — check the id before assuming an access problem.
Hi — ask anything about elsai docs. Mention the product name: elsai ARMS, Prompt Manager, Agent Framework, Core, or elsai Guardrails.