API Reference

Agents API

REST endpoints and MCP tools for the portable-agent registry: read, author, version, and export an agent.

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_agents tool 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}/versions over REST, the create_agent_version tool 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

MethodPathMCP equivalent
GET/v1/orgs/{orgSlug}/agentslist_agents, airlock-management/search_agents
POST/v1/orgs/{orgSlug}/agentsairlock-management/create_agent
GET/v1/orgs/{orgSlug}/agents/{name}describe_agent
PATCH/v1/orgs/{orgSlug}/agents/{name}airlock-management/update_agent
DELETE/v1/orgs/{orgSlug}/agents/{name}airlock-management/delete_agent
GET/v1/orgs/{orgSlug}/agents/{name}/versions/{n}describe_agent with version
POST/v1/orgs/{orgSlug}/agents/{name}/versionsairlock-management/create_agent_version
GET/v1/orgs/{orgSlug}/agents/{name}/exportexport_agent
GET/v1/orgs/{orgSlug}/model-catalogairlock-management/get_model_catalog

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.

Query parameterEffect
limit, cursorPage through the library, newest first
tagsNarrow to agents carrying these tags. Repeat the parameter once per tag (?tags=support&tags=triage), matched exactly and combined with OR
modelNarrow to agents whose model preference names this family, in any position, not only the first
searchScored keyword search over name and description. Returns a relevance-ordered result rather than a page, so no cursor comes back
facetstrue adds the facet option lists over your whole visible library
{
  "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 }
StatusWhen
400The envelope failed validation, or names a model family the catalog does not carry
409The name is already taken in this organization

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 spec writes 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.

StatusWhen
400Validation failed
403You can see this agent but did not write it. Being granted an agent lets you use it, never rewrite it for everybody else who holds a grant
404No agent of that name is visible to you. Deliberately the same answer as a name nobody holds, so a name is never an existence probe
409The new name is already taken

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.

StatusWhen
400fromVersion is not a positive integer, the stored version no longer satisfies the schema, or the old spec names a model family the catalog has since retired and the current version does not also name it. The last case carries a details array naming each family
403You can see this agent but did not write it. Same gate as PATCH
404No agent of that name is visible to you, or no version carries that number
409That spec is already the current one. The check compares the spec, not the number, so restoring v1 twice in a row is refused the second time rather than appending an identical copy

Export an agent for a host

GET /v1/orgs/{orgSlug}/agents/{name}/export?adapter=claude-code
Query parameterRequiredValue
adapterYesclaude-code, cursor, claude-sdk, openai, bedrock, gemini, vercel
versionNoExport a historical version instead of the current one
{
  "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 adapter is not one of the values above. The message lists them.
  • The agent's modelPreference names 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_agent arguments. 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

ToolPurposeWho can call it
list_agentsEvery agent visible to you, each with its current version number and registry id. Takes no argumentsAny member
describe_agentThe full envelope. Takes agent (name), id, or both, plus an optional versionAny member who can see the agent
export_agentInstall guidance for a host: how to wire the endpoint and fetch the definition per run. Takes agent / id, a required adapter, and an optional versionAny member who can see the agent

describe_agent and export_agent address an agent three ways, and the difference matters:

  • id alone is the durable form. Prefer it for anything you store.
  • agent alone 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 warning telling 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.

ToolPurposeWho can call it
search_agentsKeyword search over name, description and tags. Takes query and an optional limit. The tag half is the deliberate difference from the REST ?search=, which scores name and description only: a REST caller has the tags facet to reach a tag exactly, and an agent driving this tool does notAny member
create_agentCreate an agent and its first version from an envelopeAny member
update_agentUpdate an agent by name with a whole envelope. Registry edits apply in place, spec edits bump a versionIts author, or an admin
create_agent_versionRestore an earlier version: copies the spec of from_version into a new current version, with an optional change_summary. Append-only, so the restore can itself be undoneSame as update_agent
delete_agentDelete an agent by name, along with all of its versions and its access grants. An agent with a deployment is refused, and the refusal names the deployments that blocked itSame as update_agent
get_model_catalogThe families and models a modelPreference may declare, and the concrete routes each one can run on. Fetch at boot instead of maintaining your own mapping, and re-fetch when the catalog version changesAny member
get_model_credentialRelease one of the organization's stored model-provider API keys by credentialName. Default-deny: released only to a service account holding an explicit grant for that credential name, so human users, admins included, are always refusedA granted service account

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

StatusMeaning
400Validation failed, or a required parameter is missing
401The bearer credential is missing or not valid
403Authenticated, but not permitted: you are not a member of the named organization, your account is not active, or you are writing an agent you can see but did not author
404No such agent, no such version, or anything hidden from you. 404 rather than 403 for anything you cannot see, so existence is never disclosed. An agent you CAN see but did not write answers 403 on a write, because pretending it does not exist would deny something you can see in your own list
409A name collision, or a delete blocked by an existing deployment

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.