Your kernel, connected over HTTP. Your infrastructure runs the kernel and supplies authentication. Cloud separately receives the decision events your application sends.

HTTP API reference

createSharedOSHandler returns a plain (Request) => Promise<Response> over the Fetch API, so it mounts in Node, Bun, Deno, Next.js, Hono, or any runtime that speaks Request/Response. It exposes the same kernel that embedded consumers call in-process, over the same contracts.

import { createKernelSharedOSApi, createSharedOSHandler } from "@aicoo/sharedos";

const handler = createSharedOSHandler({
  api: createKernelSharedOSApi({ kernel, turns }),
  resolveContext: async (request) => /* trusted server-side state */,
  onError: (error, request, requestId) => logger.error({ error, requestId }),
});

api is an interface, not a class. createKernelSharedOSApi is the standard implementation over SharedOSKernel; a deployment that routes to another process can implement SharedOSApi directly.

Authentication is yours; authorization is not

There is exactly one place identity enters:

resolveContext(request: Request): Promise<AccessContext>

Everything about who is calling comes from there — your session cookie, your bearer token, your mTLS peer, your service mesh identity. SharedOS does not define a login, a token format, or a header name.

Three properties hold as a result, and they are the reason this boundary is safe to expose:

  1. No request body carries authority. The wire schemas for the two context-bearing endpoints (RemoteResourceOperation, RemoteExecutionRequest) are the embedded shapes with context — and for turns, toolsremoved. A caller cannot describe its own grants, and cannot widen its own catalog. Neither can resolveContext: an AccessContext has no grants field at all, and the kernel loads authority itself, through its GrantSource.
  2. The context you return is re-validated. It is parsed against AccessContextSchema before anything runs. A host bug that produces a malformed context is a 500 invalid_access_context, not a bypass.
  3. Transport authentication is not permission. Passing resolveContext gets you a principal. Every operation still needs a matching capability grant.

/health is the only route that never calls resolveContext.

Routes

MethodPathRequest body2xx response
GET/healthSharedOSHealth
POST/v1/authorizeCapabilityRequirementAuthorizationDecision
GET/v1/toolsToolDefinition[]
GET/v1/reachReachResult
GET/v1/tools/namespacesToolNamespaceCatalog
PUT/v1/tools/namespacesToolNamespaceUpdateToolNamespaceCatalog
POST/v1/tools/invokeToolCallToolResult
POST/v1/resources/invokeRemoteResourceOperationResourceResult
POST/v1/messagesMessageEnvelopeMessageDeliveryResult
POST/v1/turnsRemoteExecutionRequestExecutionResult

Unknown paths are 404. A known path with the wrong verb is 405.

GET /health

Liveness and protocol version. Requires no authentication and resolves no context, so it is safe as a load-balancer probe.

{ "status": "ok", "protocolVersion": "1" }

POST /v1/authorize

Ask whether an action would be allowed, without performing it. Discovery-style checks like this never consume a bounded (maxUses) grant.

{
  "resource": { "namespace": "files", "path": ["Work", "Projects", "atlas"] },
  "action": "search"
}
{ "allowed": true, "reasonCode": "allowed", "matchedGrantId": "grant-1" }

A refusal is still 200 — the decision is the payload, not the status. See reason codes.

GET /v1/tools

The effective catalog for the resolved context: registered and namespace enabled and allowed by some grant. A tool the caller may not use does not appear, so a model driven by this list never learns it exists.

[
  {
    "name": "files.search",
    "description": "Search inside a granted file path.",
    "namespace": "files",
    "source": "sharedos",
    "readWrite": "read",
    "inputSchema": { "type": "object", "required": ["path", "query"], "properties": {} },
    "requiredCapability": { "resource": { "namespace": "files", "path": [] }, "action": "search" },
    "annotations": { "readOnly": true }
  }
]

inputSchema is JSON Schema and can be handed to a model as a tool definition unchanged. The rest of the entry is the management projection: this route returns full ToolDefinitions, and requiredCapability is the discovery ceiling, not what any call will be authorized against, so it is not something a model should be told. A client that drives a model from this route projects with publishToolCatalog first — name, description, inputSchema, outputSchema, annotations, and metadata.namespace / metadata.source — which is the same PublishedToolDefinition the MCP boundary serves and what ModelDriver sends to a provider. See what crosses the boundary.

GET /v1/reach

Where the resolved context may operate, with the authority stripped out: the namespace, path, actions and scope of every place some grant would authorize something right now, and nothing about which grant, who issued it, when it expires or how many uses remain. A caller driving its own loop over this API has no turn to be told this in, so this is how it learns where to look instead of guessing paths and collecting denials.

{
  "status": "computed",
  "reach": [
    {
      "namespace": "files",
      "path": ["Work", "atlas"],
      "actions": ["read", "search"],
      "scope": "descendants"
    }
  ]
}

Descriptive, never permissive: every call is still authorized on its own, so an entry here is not a permission. It is grant reach for the whole context — the host ceiling is not consulted, and it is not narrowed to the tool catalogue, because /v1/resources/invoke is not gated by tool namespaces. A client driving a model from /v1/tools keeps the entries whose namespace one of those tools operates on (requiredCapability.resource.namespace), which is what the execution envelope does for a turn.

A reach that cannot be established is an answer, not an error:

{ "status": "unavailable", "reasonCode": "usage_store_unavailable" }

reasonCode is authority_unavailable or usage_store_unavailable, the same codes a decision fails closed with. Nothing is narrowed silently: a bounded grant whose budget cannot be read withholds the whole answer rather than quietly omitting the grant, because a reach missing a live grant looks exactly like one that is true. A spent budget simply does not appear. Nothing is consumed by asking. See ADR 0021.

GET /v1/tools/namespaces · PUT /v1/tools/namespaces

Namespaces are the product control plane: whether a family of tools should be offered in this context at all. They are off by default and are not authority — enabling calendar does not grant anything in it.

GET returns descriptors plus a summary:

{
  "namespaces": [
    { "namespace": "files", "sources": ["sharedos"], "toolCount": 12, "enabled": true },
    { "namespace": "notion", "sources": ["mcp"], "toolCount": 4, "enabled": false }
  ],
  "summary": { "total": 2, "enabled": 1, "disabled": 1 }
}

PUT applies an idempotent patch and returns the authoritative result:

{ "enable": ["files", "calendar"], "disable": ["notion"] }

The same namespace may not appear in both lists. The host store applies the patch atomically against fresh state and may narrow it by organization policy — never widen it. The response is what actually took effect, which may be less than what was asked for.

POST /v1/tools/invoke

{
  "id": "call-1",
  "tool": "files.search",
  "arguments": { "path": ["Work", "Projects", "atlas"], "query": "ship date" },
  "traceId": "trace-1",
  "requestedAt": "2026-08-24T09:00:00.000Z"
}

Four things happen in order: the arguments are parsed against the tool's schema, the exact resource is re-derived from the parsed arguments, that exact resource and action are authorized again, and only then does the handler run. Appearing in /v1/tools is not permission to invoke; changing the path in arguments cannot reach outside the grant.

{
  "callId": "call-1",
  "tool": "files.search",
  "status": "succeeded",
  "output": { "hits": [{ "text": "Atlas ships 2026-09-30." }] },
  "completedAt": "2026-08-24T09:00:00.100Z"
}

status is succeeded, denied, or failed; the latter two carry error: { code, message, retryable?, details? } instead of output.

POST /v1/resources/invoke

Direct access to a resource plane, bypassing the tool layer. Use it for host code paths that are not model-driven — your own UI, a migration, a cron job.

{
  "operationId": "op-1",
  "resource": { "namespace": "files", "path": ["Work", "Projects", "atlas", "status.md"] },
  "action": "read"
}

The context field of ResourceOperation is not accepted on the wire; the server attaches the one it resolved. Returns a ResourceResult with the same three statuses.

POST /v1/messages

Messages coordinate work. They never carry authority — sending one to an agent does not permit that agent to do anything, and does not permit you to run it.

{
  "version": "1",
  "id": "message-1",
  "sender": { "kind": "agent", "agentId": "bob-assistant" },
  "receiver": { "kind": "agent", "agentId": "alice-assistant" },
  "purpose": "atlas-status",
  "payload": { "text": "When does Atlas ship?" },
  "traceId": "trace-1",
  "createdAt": "2026-08-24T09:00:00.000Z"
}

Authorized against the recipient: namespace sharedos.messaging, action send, path scoped to the receiver's address. Delivery status is accepted (HTTP 202), delivered, denied, or failed (all HTTP 200).

POST /v1/turns

One bounded agent turn. The server lists the visible tools itself, which is why tools is absent from the wire schema.

{
  "version": "1",
  "executionId": "execution-1",
  "agent": { "kind": "agent", "agentId": "alice-assistant" },
  "message": { "...": "MessageEnvelope" },
  "state": { "optional": "resumption state you keep" },
  "options": { "maxSteps": 8, "maxToolCalls": 8, "timeoutMs": 30000 }
}

Running someone's agent requires a recipient-scoped execution grant — namespace sharedos.execution, action invoke. Without it the turn is denied at admission and nothing runs.

{
  "version": "1",
  "executionId": "execution-1",
  "traceId": "trace-1",
  "status": "succeeded",
  "output": { "answer": "2026-09-30." },
  "events": [
    { "type": "turn.started", "sequence": 0, "...": "" },
    { "type": "tool.requested", "sequence": 1, "...": "" },
    { "type": "tool.completed", "sequence": 2, "...": "" },
    { "type": "turn.completed", "sequence": 3, "...": "" }
  ],
  "startedAt": "2026-08-24T09:00:00.000Z",
  "completedAt": "2026-08-24T09:00:02.400Z"
}

status is succeeded, denied, failed, cancelled, or escalated — the last carries an escalation and no error. events is append-only and ordered by sequence; the nine types are listed in events. options is validated, not clamped: maxSteps above 1,000, maxToolCalls above 10,000, or timeoutMs above 600,000 is a 400 invalid_request, the same limits the embedded schema enforces (see contract limits).

There is no streaming endpoint. A turn is one request/response, and the event list is returned with the result.

Status codes

Statuserror.codeCause
200Success. Includes authorization denials — read status
202/v1/messages when delivery status is accepted
400invalid_jsonBody is not JSON
400invalid_requestBody does not match the v1 contract
403permission_deniedAn error carrying that code reached the handler
404not_foundUnknown path
405method_not_allowedWrong verb for a known path
500invalid_access_contextresolveContext returned something the schema rejects
500internal_errorAnything else. Details never leak into the response

The important row is the first one. A denied operation is a successful HTTP request. 403 means the request never reached the kernel's decision; 200 with "status": "denied" means the kernel decided, and the reason code tells you why. Clients that treat non-2xx as the only failure mode will read denials as successes.

Error bodies are always:

{ "error": { "code": "invalid_request", "message": "…", "requestId": "…" } }

Headers

HeaderDirectionBehaviour
x-request-idbothEchoed if supplied, generated otherwise
cache-controlresponseAlways no-store
content-typerequestBody is read as JSON on every POST and PUT

Request cancellation propagates: request.signal is passed through to the kernel and on to your providers, so an abandoned HTTP request aborts the work. A provider must honour that signal before committing a side effect.

There is no CORS handling, no rate limiting, no payload size cap, and no OPTIONS route. Those belong to your deployment edge — see release readiness.

Calling it with curl

BASE=https://sharedos.internal.example
AUTH="authorization: Bearer $TOKEN"

curl -s "$BASE/health"

curl -s "$BASE/v1/tools" -H "$AUTH"

curl -s "$BASE/v1/authorize" -H "$AUTH" -H 'content-type: application/json' \
  -d '{"resource":{"namespace":"files","path":["Work","Projects","atlas"]},"action":"search"}'

curl -s "$BASE/v1/tools/invoke" -H "$AUTH" -H 'content-type: application/json' \
  -d '{"id":"call-1","tool":"files.search","traceId":"trace-1","requestedAt":"2026-08-24T09:00:00.000Z","arguments":{"path":["Work","Projects","atlas"],"query":"ship date"}}'

Or use the typed client

SharedOSClient has one method per route and validates every response against the same schema the server used, so a version-skewed server surfaces as a client error rather than a malformed object.

import { SharedOSClient } from "@aicoo/sharedos";

const sharedos = new SharedOSClient({
  baseUrl: "https://sharedos.internal.example",
  headers: async () => ({ authorization: `Bearer ${await mintToken()}` }),
});
Client methodRoute
health()GET /health
authorize(requirement)POST /v1/authorize
listTools()GET /v1/tools
reach()GET /v1/reach
listToolNamespaces()GET /v1/tools/namespaces
updateToolNamespaces(update)PUT /v1/tools/namespaces
invokeTool(call)POST /v1/tools/invoke
invokeResource(operation)POST /v1/resources/invoke
sendMessage(envelope)POST /v1/messages
executeTurn(request)POST /v1/turns

Options are { baseUrl, token?, fetch?, headers? }. token is a value or an async function and is sent as authorization: Bearer <token>, set after headers, so it wins if both name the header; headers is a value or an async function for anything else. Each call also takes { signal?, headers?, purpose? }: purpose is sent as x-sharedos-purpose, a hint the server's resolveContext may read into the trusted context — as the quickstart's does — and never authority in itself. Failures throw SharedOSClientError with status, code, and requestId.

Choosing this boundary

Embedding the kernel in the host process is the recommended shape for products: no extra hop, and providers talk to your services directly. Reach for HTTP when process or language isolation matters more than the extra boundary — a Python harness, an untrusted runtime that must stay out of your process, or a shared kernel serving several deployments.

Whichever you choose, the authorization decision is the same one, and the context is always host-derived.

These routes are for code you control. An external coding-agent CLI is served by the other network surface — the same catalogue and the same kernel, presented as an MCP server, with its own transports and methods in the MCP API reference.