The portable-agent registry is reachable two ways: as REST endpoints on the airlock API, and as tools on your organization's MCP endpoint. They share most of their implementation and all of their permission rules, so an agent looks the same from either side.
They are not the same contract, though, and the differences matter if you are porting a client between them:
- Listing. REST paginates and filters; the
list_agentstool takes no arguments and returns everything you can see. - Writes. A REST write executes directly. An MCP management write goes through
execute_tool, so your organization's policy applies to it and it can require approval before it runs. - Rolling back. Both sides have it:
POST .../agents/{name}/versionsover REST, thecreate_agent_versiontool over MCP. They run the same operation. - Errors. REST answers with HTTP status codes; a tool call answers with a result carrying
isError. Authentication and protocol failures are the exception: those fail before a tool result exists.
Use REST from a script that holds a Cognito JWT; use the MCP tools from an agent, or from anything authenticating with a service token.
For what a portable agent is, and how the definition is shaped, start with the Portable Agents guide.
Base URL and authentication
https://api.air-lock.ai
Every request needs a bearer credential:
Authorization: Bearer <token>
This is a Cognito JWT, and only a Cognito JWT. The alk_pat_… personal tokens and alk_svc_… service tokens belong to the MCP endpoint: the REST API sits behind a JWT authorizer that rejects an opaque token before any agent handler runs. A CI script that authenticates with a service token has to reach the registry through the MCP tools below instead. See Authentication.
{orgSlug} in every path is your organization's slug. Callers must be an active member of that organization.
Endpoints at a glance
Restoring an earlier version is a write of its own on both sides, and the Versions tab in the Control Room offers it as a button. Before that route existed, the REST way to roll back was to read the version you wanted and PATCH its spec back; that still works, but the dedicated route refuses a no-op and records the restore as one.
The envelope
The envelope is the document an agent's definition travels as. A write accepts one as its request body, and the two single-agent reads return one under an envelope key. It is not the shape of every response on this page: the list returns summaries plus page metadata, an export returns install instructions, and a delete returns no body at all.
{
"agent": {
"name": "triage",
"description": "Triage incoming GitHub issues. Trigger phrases: \"triage this issue\".",
"tags": ["support"],
"license": "MIT"
},
"spec": {
"schemaVersion": "1.0",
"systemPrompt": "You triage incoming issues…",
"modelPreference": [
{ "family": "claude", "model": "sonnet-4-6" },
{ "family": "gpt", "model": "gpt-5.4" }
],
"vendorHints": { "vercel": { "maxSteps": 16 } }
}
}agent is mutable registry metadata and spec is the immutable per-version payload. name, description, schemaVersion, systemPrompt and a non-empty modelPreference are required; tags, license and vendorHints are optional and are omitted from a response rather than returned as null.
There is no ownership field. It was retired: who can use an agent is decided by access grants, on the agent's Access tab, and can change at any time. An envelope that still carries one imports cleanly and simply arrives without it, exactly as the retired spec.budget and spec.approvalMode do.
A definition carries no skills list, and sending one has no effect: unknown keys are stripped rather than rejected, so a spec.skills you send is silently dropped. Which skills an agent can reach is decided at run time by the identity it authenticates as, never by the definition.
vendorHints is opaque to airlock and always advisory. Nothing in it is enforced: vercel.maxSteps, for example, is a step cap the Vercel AI SDK install instructions ask your code to honor.
On a read, each modelPreference entry may carry an extra resolved field naming the concrete route airlock picked for it, plus a resolvedCandidates list when the model is reachable more than one way. Both are decoration added on the way out by the two GET routes and by describe_agent, never something you send, and a create or update response does not carry them. An entry comes back undecorated when airlock could not resolve it, which means "use your own mapping".
A body that fails validation comes back as 400 with every failed check listed, each message prefixed by the path it applies to:
{
"error": "Invalid agent envelope",
"details": [
"spec.systemPrompt: must not be empty",
"spec.modelPreference[0].family: must not be empty"
]
}Unknown keys are stripped rather than rejected, so a field the schema does not know about is silently dropped instead of failing the write.
List agents
GET /v1/orgs/{orgSlug}/agents
Returns the agents visible to you: the ones you authored, plus any you hold an access grant for. Organization admins see every agent.
{
"agents": [
{
"id": "a7e1c9d4-5b32-4f8e-9c06-3b1d8f27ac54",
"name": "triage",
"description": "Triage incoming GitHub issues…",
"tags": ["support"],
"versionCount": 4,
"modelPreference": [{ "family": "claude", "model": "sonnet-4-6" }],
"createdBy": "…",
"createdAt": "2026-07-02T09:12:44.001Z",
"updatedAt": "2026-08-11T15:03:20.884Z"
}
],
"nextCursor": "…",
"hasMore": true
}versionCount is the current version number, which is also the total number of versions, because versions run 1 to N with no gaps.
A returned page can be smaller than limit while hasMore is still true: rows you cannot see are filtered after the page is read. Follow the cursor rather than treating a short page as the end.
Create an agent
POST /v1/orgs/{orgSlug}/agents
The body is an envelope. Any active member of the organization may create an agent. A new one is visible to its author and nobody else until it is granted; the agent and its first version are written together, so an agent is never readable without a version.
Returns 201 with:
{ "envelope": { "agent": { … }, "spec": { … } }, "version": 1 }Read an agent
GET /v1/orgs/{orgSlug}/agents/{name}
{
"envelope": { "agent": { … }, "spec": { … } },
"version": 4,
"id": "a7e1c9d4-5b32-4f8e-9c06-3b1d8f27ac54"
}id is the agent's immutable registry id. It is not part of the envelope, and it is what you store when you need a reference that survives a rename. It is also what access grants key on.
An agent you have no grant for answers 404, not 403, so nobody can probe for the existence of agents they cannot see.
Update an agent
PATCH /v1/orgs/{orgSlug}/agents/{name}
The body is a whole envelope, not a partial one. What happens to it depends on which half changed:
- A change under
agent(name, description, tags, license) applies in place. - Any change under
specwrites a new version and makes it current. - Both at once does both, in one request.
- Neither is a no-op that still returns the current state.
Returns 200 with { envelope, version }, where version is the current version after the update.
A name is unique within your organization, so it addresses exactly one agent: reads and writes resolve to the same one, and there is no ambiguity to disambiguate.
Delete an agent
DELETE /v1/orgs/{orgSlug}/agents/{name}
Removes the agent, every one of its versions, and its access grants. Returns 204.
An agent with any deployment cannot be deleted, because the deployment pins a version the delete would take away. That comes back as 409 naming the deployments that blocked it:
{
"error": "This agent still has deployments, including: \"prod\" (production). Delete its deployments first.",
"deployments": [{ "id": "…", "name": "prod", "environment": "production" }]
}Treat that list as indicative rather than exhaustive. A deployment created moments earlier may not appear in it yet.
Read a historical version
GET /v1/orgs/{orgSlug}/agents/{name}/versions/{n}
Versions are numbered from 1. Returns { envelope, version } with that version's authored spec, projected through the current schema. Registry metadata is not versioned, so the agent half is always current.
That projection matters for old versions: fields the schema has since dropped are stripped on read, and family and model names are trimmed. So a version stored a while ago comes back as what it means today, not byte-for-byte as it was written.
With one deliberate exception: a stored spec that no longer satisfies the schema at all is returned raw, unprojected, rather than being refused. So a very old or hand-edited version can still come back carrying fields the schema no longer knows. Treat the projection as the normal case, not a guarantee.
The response is not byte-stable across catalog changes. The resolved / resolvedCandidates decoration on each modelPreference entry is computed against the catalog as it stands now, not as it stood at that version, so the same historical read can answer differently after a catalog publish. Only the authored fields are genuinely historical. A client pinning a version should compare those, not the whole document.
400 for a version number that is not a positive integer, 404 for one that does not exist.
Restore an earlier version
POST /v1/orgs/{orgSlug}/agents/{name}/versions
{ "fromVersion": 2, "changeMessage": "Back to the calmer prompt" }Copies the spec of fromVersion into a new version and makes it current. Nothing is rewritten: the history keeps every version, including the one you restored from, so the restore can itself be undone by restoring again. changeMessage is optional and defaults to Restored from v2.
Registry metadata is not versioned, so the agent half (name, description, tags, license) is left exactly as it is. A rename made after the version you are restoring stays.
The restored spec is projected through the current schema first, the same projection a historical read applies, so a version stored before a field was dropped comes back without it.
Returns 201 with { envelope, version, restoredFromVersion }, where version is the number of the new current version.
Export an agent for a host
GET /v1/orgs/{orgSlug}/agents/{name}/export?adapter=claude-code
{
"format": "claude-code",
"agentName": "triage",
"artifacts": [],
"installInstructions": "There is NO file to install. Claude Code reaches this agent over MCP…"
}agentName is the name the export was rendered under. On this route it always equals the name in the path, because the path is what resolves the agent. It exists for parity with export_agent, which can address an agent by immutable id and therefore can return a name that differs from the one you asked for.
artifacts is always empty, for every target. No host is handed a file: each one connects to your MCP endpoint and fetches the definition per run, so the whole answer is in installInstructions. An empty list is a valid, successful export, not a failure. The field remains on the response so existing callers keep parsing it.
Exports fail with 400 in three cases worth knowing:
- The
adapteris not one of the values above. The message lists them. - The agent's
modelPreferencenames nothing this host can run. That is deliberate: the target's model table is as real a constraint as the wire format, and a silent substitution would be worse than a refusal. - The agent's name is not safe to render into generated instructions, a pasted shell command, or the
describe_agentarguments. Names are only required to be non-blank at the registry, so this is checked at export time instead.
The MCP tools
Three of the agent tools are meta-tools on the org endpoint, callable directly. The rest are management tools under the airlock-management namespace, which you invoke through execute_tool.
Meta-tools
describe_agent and export_agent address an agent three ways, and the difference matters:
idalone is the durable form. Prefer it for anything you store.agentalone works, but a name is mutable and reusable, so a stored name-keyed reference can be orphaned by a rename, or silently re-bound when a new agent takes the freed name.- Both together resolves on the id and compares the name. When they disagree, the result carries a
warningtelling you the agent was renamed, so a caller that stored the old name can correct it.
An id that does not resolve never falls back to the name.
Management tools
Invoke these through execute_tool with the namespaced name, for example airlock-management/create_agent.
That namespace is the management project's slug, which is airlock-management by default but is not a constant. If a call comes back tool-not-found, read the authoritative name from list_services rather than assuming the default.
Two notes on permissions that matter if you are automating against these.
A tool being visible is not a tool being allowed. Admin-only management tools are hidden from a non-admin's tool list, but the authorization decision is made by the handler on every call, not by that filter.
Authorship drives the write gate, not the grant. update_agent, create_agent_version and delete_agent are available to an agent's author and to organization admins, and to nobody else. Being granted an agent lets you use it, never rewrite it for everybody else who holds a grant. Exactly how the REST routes behave.
Errors
Over MCP the same conditions come back as a tool result with isError set and the message in the text content, rather than as an HTTP status.