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.
  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/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.

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" },
  "intent": "answer-question",
  "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, or cancelled. events is append-only and ordered by sequence; the eight types are listed in events. options is clamped by the server — maxToolCalls at most 10,000, timeoutMs at most 600,000.

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
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, fetch?, headers? }, where headers is a value or an async function. Each call also takes { signal?, headers? }. 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.