Ghost Harness API
Version 1.2.1
This is Ghost's harness surface: the agent loop, offered as a service.
Ghost is also a product in its own right, with its own web and mobile clients. Those clients use the same core through the same contract. What this document defines is the boundary a *separate* product binds to, so it can delegate execution to Ghost without reading Ghost's source.
## Model
A tenant owns everything. An agent is a configured persona plus a tool allowlist. A session is a stateful thread of interaction with one agent. Posting a message into a session starts a run — one pass of the agent loop, which may call tools, may suspend for approval, and eventually produces output.
Ghost's internal storage calls a session a conversation and a run a job. Those names are not part of this contract and may change.
## Sessions are long-lived, and their history has a ceiling
A session is not a one-shot. It can carry a conversation for days, and each run on it replays what came before — but only the most recent N messages, where N defaults to 40 and is per-tenant configurable. The ceiling is a message count, not a token budget.
Every run reports the window it ran under as run.history, including truncated. Do not infer truncation from the agent behaving oddly: an agent that has lost the start of a conversation looks exactly like one ignoring it. GET /v1/sessions/{sessionId} reports message_count and history_window, so you can see truncation coming before you post rather than read about it afterwards.
A run loads its history once, at the start, so nothing recorded while it is in flight reaches it. run.history.recorded_after_ingress counts what a run could not see; non-zero means its output is stale. An autonomous loop that auto-sends should check it before sending.
Two things follow for anything with a human in the loop:
- When something is said to your customer outside Ghost — a colleague taking the thread over outside Ghost — record it with mode: "record" on POST /v1/sessions/{sessionId}/messages. It appends to the transcript and starts nothing. Without it the agent resumes contradicting whoever just spoke. - When history.truncated goes true and the early context still matters, open a fresh session carrying a summary as context[]. That is the durable fix; the window is not.
## Two response modes
Every run is created the same way. How you read it differs:
- mode: "async" (default) returns 202 with a queued run. Poll GET /v1/runs/{runId} or subscribe to GET /v1/runs/{runId}/events. - mode: "sync" blocks until the run *stops* and returns 200 with the run and its output. Bounded by timeout_ms; exceeding it returns 504 and the run continues in the background.
Stopping includes suspension. A run that reaches awaiting_approval or awaiting_token_refresh is returned immediately rather than waited on: nothing about it will change until you act, so holding the connection to the full timeout_ms would spend the budget to tell you nothing and bury the pending_approval payload that says what to do next.
Note that timeout_ms accepts up to 300000, but a sync request will not hold its connection beyond ~110s whatever you ask for — past that the platform closes the invocation, and a clean 504 is more useful than a dropped socket. For work that may run longer, use async and the event stream.
Interactive UIs want async plus the event stream. Server-to-server callers usually want sync.
## Suspension
A run can stop without being finished or failed. Two reasons, both durable across process restarts and both held indefinitely:
- awaiting_approval — the agent called a tool the tenant has gated. Resolve with POST /v1/runs/{runId}/approval. Approving resumes the loop from where it paused; denying feeds the refusal back to the model, which acknowledges it and stops. - awaiting_token_refresh — the caller's session token expired mid-run. Resolve with POST /v1/runs/{runId}/resume carrying a fresh token. The run is not failed: a run suspended for approval can wait hours, and losing that work to a routine token rollover would be a bug, not a policy.
The two are distinct statuses so a caller can tell "waiting on a human" from "send me a fresh credential" without inspecting anything else.
### Finding out about a suspension
Both suspensions are emitted on GET /v1/runs/{runId}/events, which is enough when a UI has the stream open. When nothing is watching — a voice or SMS agent, or any run nobody is sitting in front of — set approval_webhook_url on the tenant and Ghost posts the awaiting_approval event to it as JSON.
The body is the same shape the stream sends, so one parser serves both. Signed as X-Ghost-Signature: t=<unix>,v1=<hex>, an HMAC-SHA256 over <t>.<raw body> using the secret returned when the URL was set — the same scheme Stripe uses, verified the same way. run_id plus seq is the idempotency key: a redelivery repeats both, so duplicates can be discarded without asking us anything.
Delivery is a convenience over a durable state, not the state itself. It is attempted after the suspension is committed, retried twice on a 5xx or a network fault, and then abandoned — a callback Ghost could not deliver leaves the run exactly as suspended and exactly as resolvable as one it could.
## Authentication
Three roles, resolved before any per-endpoint permission check:
| Role | Caller | Credential | |---|---|---| | user | Ghost's own web and mobile clients | UserJwt | | tenant | an integrating product, via this API | TenantApiKey | | control | provisioning | ControlPlaneKey |
Role authorization runs first. A tenant caller can never reach a control-plane operation and vice versa, even if a scope check is wrong.
Ghost issues the tenant credential. A key is minted through the control-plane key-management endpoints, returned exactly once at creation, and stored only as a hash. Revoking a key takes effect on the next request: there is no window during which a withdrawn credential still works.
A key may carry an optional expiry. If it does, a run started with that key stops taking new actions once it passes, and suspends as awaiting_token_refresh rather than failing — resume it with a fresh key via POST /v1/runs/{runId}/resume. Keys without an expiry, which is the default, never enter that state.
## Errors
Every non-2xx response is an Error object. error.request_id correlates with server logs and should be included in any bug report.
## Compatibility
This is a published interface. Within a major version, changes are additive: new endpoints, new optional request fields, new response fields, new enum members. Clients must ignore unknown response fields and tolerate unknown enum members. Anything else requires a new major version and a new path prefix.
Every example on this page uses $GHOST_API. Set it once:
export GHOST_API="https://api.ghostagents.co"
This API refuses cross-origin browser requests that carry
X-Ghost-Api-Key — the header is not in its CORS allow-list, so a browser
will block the request before it is sent. Call it from your server. Manage keys, watch
usage and read your request log in the console.
Health
Liveness and readiness. Unauthenticated.
/.well-known/jwks.json
Public keys for the outbound caller-identity header
Ghost signs an attribution header onto every outbound MCP call: Ghost-Caller-Identity: <compact JWS>, EdDSA over Ed25519, with kid in the JOSE header. This endpoint publishes the public halves so your MCP server can verify it. Unauthenticated, because a verifier has to reach it before it can trust anything, and its contents are public by construction.
Claims: iss, aud (the server slug the call is going to), iat, exp (120s after iat), tenant_id, agent_id, session_id, run_id.
Attribution, not authorisation. The header never widens what a caller may reach — scope is the credential, and the credential alone. There is deliberately nothing in the claim set a server could mistake for permission: no scopes, no grants, no tool list. If the header and the token disagree about who is calling, reject; do not believe either.
keys is empty when no signing key is configured, in which case no header is sent at all. That is the default, and it is a truthful "nothing here signs" rather than a 404 a client would special-case.
Rotation adds a key at the front and publishes every public half, so a token signed a minute before a rotation still verifies. Poll this rather than pinning a key.
| Code | Body | |
|---|---|---|
| 200 | object |
A JWK Set. |
curl "$GHOST_API/.well-known/jwks.json"
/healthz
Liveness probe
Returns 200 whenever the process is running. Never touches the database.
| Code | Body | |
|---|---|---|
| 200 | Liveness |
Process is alive. |
curl "$GHOST_API/healthz"
/readyz
Readiness probe
Returns 200 only when every dependency needed to serve traffic is reachable. Returns 503 with per-dependency detail otherwise. Load balancers should route on this, not /healthz.
| Code | Body | |
|---|---|---|
| 200 | Readiness |
All dependencies healthy. |
| 503 | Readiness |
One or more dependencies unavailable. |
curl "$GHOST_API/readyz"
/openapi.json
Fetch this API's OpenAPI document
The contract itself, as OpenAPI 3.1 JSON, so a client can generate against the running instance rather than a copy that may have drifted from it.
Unauthenticated: a published interface is not a secret, and requiring a credential to read it would mean a caller needs working auth before it can generate the code that does auth.
| Code | Body | |
|---|---|---|
| 200 | object |
The OpenAPI 3.1 document. |
curl "$GHOST_API/openapi.json"
Tenants
Tenant provisioning.
/v1/tenants
Control plane
List tenants
Control plane only.
| Name | In | Type | Notes | |
|---|---|---|---|---|
limit |
query | integer | optional | Page size. |
cursor |
query | string | optional | Opaque cursor from a previous response's next_cursor. |
| Code | Body | |
|---|---|---|
| 200 | TenantList |
A page of tenants. |
| 401 | Error |
Missing or invalid credential. |
| 403 | Error |
Authenticated, but this credential type may not perform this operation. Note that cross-tenant resource access returns 404, not 403. |
curl "$GHOST_API/v1/tenants" \ -H 'X-Ghost-Control-Key: …'
/v1/tenants
Control plane
Create a tenant
Control plane only. A session token cannot create tenants.
TenantCreate
| Name | In | Type | Notes | |
|---|---|---|---|---|
Idempotency-Key |
header | string | optional | Client-generated key making this request safe to retry. Replaying a key within 24 hours returns the original response instead of acting twice. Reusing a key with a different body returns 409. |
| Code | Body | |
|---|---|---|
| 201 | Tenant |
Tenant created. |
| 400 | Error |
Malformed request. |
| 401 | Error |
Missing or invalid credential. |
| 403 | Error |
Authenticated, but this credential type may not perform this operation. Note that cross-tenant resource access returns 404, not 403. |
| 409 | Error |
The resource is not in a state that permits this operation. |
| 422 | Error |
Well-formed but semantically invalid. details lists each failure. |
curl -X POST "$GHOST_API/v1/tenants" \
-H 'X-Ghost-Control-Key: …' \
-H 'Content-Type: application/json' \
-d '{ … }'
/v1/tenants/{tenantId}
Control planeAPI key
Get a tenant
A session token may read only its own tenant. Any other tenantId returns 404, not 403 — a tenant must not be able to probe for the existence of other tenants.
| Name | In | Type | Notes | |
|---|---|---|---|---|
tenantId |
path | string (uuid) | required |
| Code | Body | |
|---|---|---|
| 200 | Tenant |
The tenant. |
| 401 | Error |
Missing or invalid credential. |
| 404 | Error |
No such resource in this tenant. Also returned when a resource exists but belongs to another tenant, so existence cannot be probed. |
curl "$GHOST_API/v1/tenants/{tenantId}" \
-H 'X-Ghost-Api-Key: ghost_sk_…'
/v1/tenants/{tenantId}
Control planeAPI key
Update a tenant
Also where the approval callback is configured. Setting approval_webhook_url returns approval_webhook_secret in this response and in no other — see the field's own description for the minting rule.
TenantUpdate
| Name | In | Type | Notes | |
|---|---|---|---|---|
tenantId |
path | string (uuid) | required |
| Code | Body | |
|---|---|---|
| 200 | TenantUpdated |
Updated tenant. |
| 400 | Error |
Malformed request. |
| 401 | Error |
Missing or invalid credential. |
| 404 | Error |
No such resource in this tenant. Also returned when a resource exists but belongs to another tenant, so existence cannot be probed. |
| 422 | Error |
Well-formed but semantically invalid. details lists each failure. |
curl -X PATCH "$GHOST_API/v1/tenants/{tenantId}" \
-H 'X-Ghost-Api-Key: ghost_sk_…' \
-H 'Content-Type: application/json' \
-d '{ … }'
/v1/tenants/{tenantId}
Control plane
Delete a tenant
Control plane only. Irreversible. Cancels running runs, revokes all API keys, and destroys tenant data including credentials.
| Name | In | Type | Notes | |
|---|---|---|---|---|
tenantId |
path | string (uuid) | required |
| Code | Body | |
|---|---|---|
| 204 | — | Tenant deleted. |
| 401 | Error |
Missing or invalid credential. |
| 403 | Error |
Authenticated, but this credential type may not perform this operation. Note that cross-tenant resource access returns 404, not 403. |
| 404 | Error |
No such resource in this tenant. Also returned when a resource exists but belongs to another tenant, so existence cannot be probed. |
curl -X DELETE "$GHOST_API/v1/tenants/{tenantId}" \
-H 'X-Ghost-Control-Key: …'
/v1/tenants/{tenantId}/keys
Control plane
List a tenant's API keys
Metadata only. Nothing in this response can reconstruct a key, which is what makes listing safe to expose at all. Revoked keys are included, so an audit can see what once existed.
| Name | In | Type | Notes | |
|---|---|---|---|---|
tenantId |
path | string (uuid) | required |
| Code | Body | |
|---|---|---|
| 200 | object |
The tenant's keys. |
| 401 | Error |
Missing or invalid credential. |
| 403 | Error |
Authenticated, but this credential type may not perform this operation. Note that cross-tenant resource access returns 404, not 403. |
| 404 | Error |
No such resource in this tenant. Also returned when a resource exists but belongs to another tenant, so existence cannot be probed. |
curl "$GHOST_API/v1/tenants/{tenantId}/keys" \
-H 'X-Ghost-Control-Key: …'
/v1/tenants/{tenantId}/keys
Control plane
Mint an API key for a tenant
Returns the key in plaintext. This is the only response that will ever contain it: Ghost stores a hash and a display prefix, so a key that is lost must be revoked and replaced rather than recovered.
Control-plane only. A tenant cannot mint its own keys — a stolen key would otherwise be able to issue itself a replacement and survive the revocation of the key that was noticed.
expires_at is optional and must be in the future. A key that expires gives runs started with it a credential horizon: past it the agent stops taking new actions and the run suspends as awaiting_token_refresh instead of failing.
ApiKeyCreate
| Name | In | Type | Notes | |
|---|---|---|---|---|
tenantId |
path | string (uuid) | required | |
Idempotency-Key |
header | string | optional | Client-generated key making this request safe to retry. Replaying a key within 24 hours returns the original response instead of acting twice. Reusing a key with a different body returns 409. |
| Code | Body | |
|---|---|---|
| 201 | ApiKeyWithSecret |
Key created. The plaintext is in this response only. |
| 401 | Error |
Missing or invalid credential. |
| 403 | Error |
Authenticated, but this credential type may not perform this operation. Note that cross-tenant resource access returns 404, not 403. |
| 404 | Error |
No such resource in this tenant. Also returned when a resource exists but belongs to another tenant, so existence cannot be probed. |
| 422 | Error |
Well-formed but semantically invalid. details lists each failure. |
curl -X POST "$GHOST_API/v1/tenants/{tenantId}/keys" \
-H 'X-Ghost-Control-Key: …' \
-H 'Content-Type: application/json' \
-d '{ … }'
/v1/tenants/{tenantId}/keys/{keyId}
Control plane
Revoke an API key
Takes effect on the next request. The row is marked rather than deleted, so an audit of what could have made a given call survives the revocation.
Idempotent: revoking an already-revoked key reports the key as it stands rather than erroring, because "ensure this key cannot be used" is satisfied either way.
| Name | In | Type | Notes | |
|---|---|---|---|---|
tenantId |
path | string (uuid) | required | |
keyId |
path | string (uuid) | required |
| Code | Body | |
|---|---|---|
| 200 | ApiKey |
The revoked key. |
| 401 | Error |
Missing or invalid credential. |
| 403 | Error |
Authenticated, but this credential type may not perform this operation. Note that cross-tenant resource access returns 404, not 403. |
| 404 | Error |
No such resource in this tenant. Also returned when a resource exists but belongs to another tenant, so existence cannot be probed. |
curl -X DELETE "$GHOST_API/v1/tenants/{tenantId}/keys/{keyId}" \
-H 'X-Ghost-Control-Key: …'
Agents
Agent configuration within a tenant.
/v1/agents
API keySession
List agents
| Name | In | Type | Notes | |
|---|---|---|---|---|
limit |
query | integer | optional | Page size. |
cursor |
query | string | optional | Opaque cursor from a previous response's next_cursor. |
kind |
query | "standard" | "meta" | optional | |
status |
query | "draft" | "active" | "paused" | optional | |
origin |
query | "app" | "api" | "all" | optional | Which surface created the rows. app = made in the GhostAgents product, api = made through this API. Defaults to all.
Note the asymmetry: the GhostAgents apps never show |
| Code | Body | |
|---|---|---|
| 200 | AgentList |
A page of agents. |
| 401 | Error |
Missing or invalid credential. |
curl "$GHOST_API/v1/agents" \ -H 'X-Ghost-Api-Key: ghost_sk_…'
/v1/agents
API keySession
Create an agent
AgentCreate
| Name | In | Type | Notes | |
|---|---|---|---|---|
Idempotency-Key |
header | string | optional | Client-generated key making this request safe to retry. Replaying a key within 24 hours returns the original response instead of acting twice. Reusing a key with a different body returns 409. |
| Code | Body | |
|---|---|---|
| 201 | Agent |
Agent created. |
| 400 | Error |
Malformed request. |
| 401 | Error |
Missing or invalid credential. |
| 422 | Error |
Well-formed but semantically invalid. details lists each failure. |
curl -X POST "$GHOST_API/v1/agents" \
-H 'X-Ghost-Api-Key: ghost_sk_…' \
-H 'Content-Type: application/json' \
-d '{ … }'
/v1/agents/{agentId}
API keySession
Get an agent
| Name | In | Type | Notes | |
|---|---|---|---|---|
agentId |
path | string (uuid) | required |
| Code | Body | |
|---|---|---|
| 200 | Agent |
The agent. |
| 401 | Error |
Missing or invalid credential. |
| 404 | Error |
No such resource in this tenant. Also returned when a resource exists but belongs to another tenant, so existence cannot be probed. |
curl "$GHOST_API/v1/agents/{agentId}" \
-H 'X-Ghost-Api-Key: ghost_sk_…'
/v1/agents/{agentId}
API keySession
Update an agent
AgentUpdate
| Name | In | Type | Notes | |
|---|---|---|---|---|
agentId |
path | string (uuid) | required |
| Code | Body | |
|---|---|---|
| 200 | Agent |
Updated agent. |
| 400 | Error |
Malformed request. |
| 401 | Error |
Missing or invalid credential. |
| 404 | Error |
No such resource in this tenant. Also returned when a resource exists but belongs to another tenant, so existence cannot be probed. |
| 422 | Error |
Well-formed but semantically invalid. details lists each failure. |
curl -X PATCH "$GHOST_API/v1/agents/{agentId}" \
-H 'X-Ghost-Api-Key: ghost_sk_…' \
-H 'Content-Type: application/json' \
-d '{ … }'
/v1/agents/{agentId}
API keySession
Delete an agent
Fails with 409 if the agent has running or suspended runs. Cancel them first.
| Name | In | Type | Notes | |
|---|---|---|---|---|
agentId |
path | string (uuid) | required |
| Code | Body | |
|---|---|---|
| 204 | — | Agent deleted. |
| 401 | Error |
Missing or invalid credential. |
| 404 | Error |
No such resource in this tenant. Also returned when a resource exists but belongs to another tenant, so existence cannot be probed. |
| 409 | Error |
The resource is not in a state that permits this operation. |
curl -X DELETE "$GHOST_API/v1/agents/{agentId}" \
-H 'X-Ghost-Api-Key: ghost_sk_…'
/v1/agents/{agentId}/ask
API keySession
Ask an agent in one call
One-shot convenience over sessions + messages for platforms housing automations behind an agent. Without session_id this opens a fresh session for the agent and asks in it; with one it continues that session (which must belong to this agent).
mode: stream returns the run's SSE event stream directly — the same frames GET /v1/runs/{runId}/events sends. async/sync return the run like the messages endpoint does. All ingress gates apply identically: this is a shorter path to the same run, not a second loop.
AgentAsk
| Name | In | Type | Notes | |
|---|---|---|---|---|
agentId |
path | string (uuid) | required | |
Idempotency-Key |
header | string | optional | Client-generated key making this request safe to retry. Replaying a key within 24 hours returns the original response instead of acting twice. Reusing a key with a different body returns 409. |
| Code | Body | |
|---|---|---|
| 200 | Run |
Returned when mode is sync or stream. For sync the body is the settled run; for stream it is the SSE event stream. |
| 202 | Run |
Returned when mode is async. The run is queued. |
| 400 | Error |
Malformed request. |
| 401 | Error |
Missing or invalid credential. |
| 402 | Error |
The tenant's allowance is exhausted. The circuit breaker is open. |
| 404 | Error |
No such resource in this tenant. Also returned when a resource exists but belongs to another tenant, so existence cannot be probed. |
| 409 | Error |
The resource is not in a state that permits this operation. |
| 422 | Error |
Well-formed but semantically invalid. details lists each failure. |
| 429 | Error |
Too many requests. |
| 504 | Error |
mode: sync exceeded timeout_ms. The run was not cancelled and continues in the background; error.run_id identifies it. |
curl -X POST "$GHOST_API/v1/agents/{agentId}/ask" \
-H 'X-Ghost-Api-Key: ghost_sk_…' \
-H 'Content-Type: application/json' \
-d '{ … }'
/v1/agents/{agentId}/workflows
API keySession
List an agent's workflows
Structured read of one agent's automations (workflow_artifact cards). Newest version per workflow, archived hidden unless include_archived=true.
| Name | In | Type | Notes | |
|---|---|---|---|---|
agentId |
path | string (uuid) | required | |
status |
query | string | optional | |
query |
query | string | optional | Case-insensitive substring filter on title. |
include_archived |
query | boolean | optional | |
limit |
query | integer | optional |
| Code | Body | |
|---|---|---|
| 200 | WorkflowList |
This agent's workflows. |
| 401 | Error |
Missing or invalid credential. |
| 404 | Error |
No such resource in this tenant. Also returned when a resource exists but belongs to another tenant, so existence cannot be probed. |
curl "$GHOST_API/v1/agents/{agentId}/workflows" \
-H 'X-Ghost-Api-Key: ghost_sk_…'
/v1/agents/{agentId}/workflows/{workflowId}
API keySession
Get one agent workflow in full
The full graph — every step with its prompt, tool binding and judge, plus edges and the last test result. Send the revised graph to the agent (ask) to change it.
| Name | In | Type | Notes | |
|---|---|---|---|---|
agentId |
path | string (uuid) | required | |
workflowId |
path | string | required |
| Code | Body | |
|---|---|---|
| 200 | Workflow |
The workflow. |
| 401 | Error |
Missing or invalid credential. |
| 404 | Error |
No such resource in this tenant. Also returned when a resource exists but belongs to another tenant, so existence cannot be probed. |
curl "$GHOST_API/v1/agents/{agentId}/workflows/{workflowId}" \
-H 'X-Ghost-Api-Key: ghost_sk_…'
Sessions
Session lifecycle.
/v1/sessions
API keySession
List sessions
| Name | In | Type | Notes | |
|---|---|---|---|---|
limit |
query | integer | optional | Page size. |
cursor |
query | string | optional | Opaque cursor from a previous response's next_cursor. |
agent_id |
query | string (uuid) | optional | |
status |
query | "active" | "archived" | optional | |
origin |
query | "app" | "api" | "all" | optional | Which surface opened the session. app = a conversation in the GhostAgents product, api = a session opened through this API. Defaults to all. See the same parameter on GET /v1/agents. |
| Code | Body | |
|---|---|---|
| 200 | SessionList |
A page of sessions, most recently active first. |
| 401 | Error |
Missing or invalid credential. |
curl "$GHOST_API/v1/sessions" \ -H 'X-Ghost-Api-Key: ghost_sk_…'
/v1/sessions
API keySession
Create a session
SessionCreate
| Name | In | Type | Notes | |
|---|---|---|---|---|
Idempotency-Key |
header | string | optional | Client-generated key making this request safe to retry. Replaying a key within 24 hours returns the original response instead of acting twice. Reusing a key with a different body returns 409. |
| Code | Body | |
|---|---|---|
| 201 | Session |
Session created. |
| 400 | Error |
Malformed request. |
| 401 | Error |
Missing or invalid credential. |
| 404 | Error |
The referenced agent does not exist in this tenant. |
| 422 | Error |
Well-formed but semantically invalid. details lists each failure. |
curl -X POST "$GHOST_API/v1/sessions" \
-H 'X-Ghost-Api-Key: ghost_sk_…' \
-H 'Content-Type: application/json' \
-d '{ … }'
/v1/sessions/{sessionId}
API keySession
Get a session
| Name | In | Type | Notes | |
|---|---|---|---|---|
sessionId |
path | string (uuid) | required |
| Code | Body | |
|---|---|---|
| 200 | Session |
The session. |
| 401 | Error |
Missing or invalid credential. |
| 404 | Error |
No such resource in this tenant. Also returned when a resource exists but belongs to another tenant, so existence cannot be probed. |
curl "$GHOST_API/v1/sessions/{sessionId}" \
-H 'X-Ghost-Api-Key: ghost_sk_…'
/v1/sessions/{sessionId}
API keySession
Update a session
Title and archived state only. Message history is immutable.
SessionUpdate
| Name | In | Type | Notes | |
|---|---|---|---|---|
sessionId |
path | string (uuid) | required |
| Code | Body | |
|---|---|---|
| 200 | Session |
Updated session. |
| 400 | Error |
Malformed request. |
| 401 | Error |
Missing or invalid credential. |
| 404 | Error |
No such resource in this tenant. Also returned when a resource exists but belongs to another tenant, so existence cannot be probed. |
| 422 | Error |
Well-formed but semantically invalid. details lists each failure. |
curl -X PATCH "$GHOST_API/v1/sessions/{sessionId}" \
-H 'X-Ghost-Api-Key: ghost_sk_…' \
-H 'Content-Type: application/json' \
-d '{ … }'
/v1/sessions/{sessionId}
API keySession
Delete a session
Deletes the session and its messages. Fails with 409 if a run is currently active or suspended.
| Name | In | Type | Notes | |
|---|---|---|---|---|
sessionId |
path | string (uuid) | required |
| Code | Body | |
|---|---|---|
| 204 | — | Session deleted. |
| 401 | Error |
Missing or invalid credential. |
| 404 | Error |
No such resource in this tenant. Also returned when a resource exists but belongs to another tenant, so existence cannot be probed. |
| 409 | Error |
The resource is not in a state that permits this operation. |
curl -X DELETE "$GHOST_API/v1/sessions/{sessionId}" \
-H 'X-Ghost-Api-Key: ghost_sk_…'
Messages
Message ingress and egress.
/v1/sessions/{sessionId}/messages
API keySession
List messages in a session
Message egress. Chronological, oldest first.
| Name | In | Type | Notes | |
|---|---|---|---|---|
sessionId |
path | string (uuid) | required | |
limit |
query | integer | optional | Page size. |
cursor |
query | string | optional | Opaque cursor from a previous response's next_cursor. |
role |
query | "user" | "agent" | "card" | optional |
| Code | Body | |
|---|---|---|
| 200 | MessageList |
A page of messages. |
| 401 | Error |
Missing or invalid credential. |
| 404 | Error |
No such resource in this tenant. Also returned when a resource exists but belongs to another tenant, so existence cannot be probed. |
curl "$GHOST_API/v1/sessions/{sessionId}/messages" \
-H 'X-Ghost-Api-Key: ghost_sk_…'
/v1/sessions/{sessionId}/messages
API keySession
Post a message, starting a run or recording without one
Message ingress. The content is treated as data throughout: it is never interpolated into a position where the model can read it as an instruction, regardless of what it contains.
With mode: "record" this appends to the transcript and returns 201 with a Message. Nothing below applies to a record: it starts no run, so there is nothing to conflict with and nothing to queue behind. See the field's own description.
Otherwise it starts a run, and:
Fails with 409 if the session already has an active or suspended run. One run per session at a time. There is no queue behind the 409: buffer the message yourself and re-post once the run settles.
Fails with 429 if the tenant already has its maximum number of runs in flight across all sessions. This is a concurrency ceiling rather than a request-rate window, so Retry-After is a hint — a slot frees when one of your runs finishes. The 409 is checked first, so a busy session always reports as a session conflict rather than as back-pressure.
MessageCreate
| Name | In | Type | Notes | |
|---|---|---|---|---|
sessionId |
path | string (uuid) | required | |
Idempotency-Key |
header | string | optional | Client-generated key making this request safe to retry. Replaying a key within 24 hours returns the original response instead of acting twice. Reusing a key with a different body returns 409. |
| Code | Body | |
|---|---|---|
| 200 | Run |
Returned when mode is sync. The run reached a terminal state. A run that ended at awaiting_approval is also returned here — check status rather than assuming success. |
| 201 | Message |
Returned when mode is record. The message is in the transcript and no run was started. |
| 202 | Run |
Returned when mode is async. The run is queued. |
| 400 | Error |
Malformed request. |
| 401 | Error |
Missing or invalid credential. |
| 402 | Error |
The tenant's allowance is exhausted. The circuit breaker is open. |
| 404 | Error |
No such resource in this tenant. Also returned when a resource exists but belongs to another tenant, so existence cannot be probed. |
| 409 | Error |
The resource is not in a state that permits this operation. |
| 422 | Error |
Well-formed but semantically invalid. details lists each failure. |
| 429 | Error |
Too many requests. |
| 504 | Error |
mode: sync exceeded timeout_ms. The run was not cancelled and continues in the background; error.run_id identifies it. |
curl -X POST "$GHOST_API/v1/sessions/{sessionId}/messages" \
-H 'X-Ghost-Api-Key: ghost_sk_…' \
-H 'Content-Type: application/json' \
-d '{ … }'
/v1/sessions/{sessionId}/messages/{messageId}
API keySession
Get a message
| Name | In | Type | Notes | |
|---|---|---|---|---|
sessionId |
path | string (uuid) | required | |
messageId |
path | string (uuid) | required |
| Code | Body | |
|---|---|---|
| 200 | Message |
The message. |
| 401 | Error |
Missing or invalid credential. |
| 404 | Error |
No such resource in this tenant. Also returned when a resource exists but belongs to another tenant, so existence cannot be probed. |
curl "$GHOST_API/v1/sessions/{sessionId}/messages/{messageId}" \
-H 'X-Ghost-Api-Key: ghost_sk_…'
Runs
Run status, events, approval, and cancellation.
/v1/runs
API keySession
List runs
| Name | In | Type | Notes | |
|---|---|---|---|---|
limit |
query | integer | optional | Page size. |
cursor |
query | string | optional | Opaque cursor from a previous response's next_cursor. |
session_id |
query | string (uuid) | optional | |
agent_id |
query | string (uuid) | optional | |
status |
query | "queued" | "running" | "awaiting_approval" | "awaiting_token_refresh" | "succeeded" | "failed" | "cancelled" | optional |
| Code | Body | |
|---|---|---|
| 200 | RunList |
A page of runs, newest first. |
| 401 | Error |
Missing or invalid credential. |
curl "$GHOST_API/v1/runs" \ -H 'X-Ghost-Api-Key: ghost_sk_…'
/v1/runs/{runId}
API keySession
Get a run
| Name | In | Type | Notes | |
|---|---|---|---|---|
runId |
path | string (uuid) | required |
| Code | Body | |
|---|---|---|
| 200 | Run |
The run. |
| 401 | Error |
Missing or invalid credential. |
| 404 | Error |
No such resource in this tenant. Also returned when a resource exists but belongs to another tenant, so existence cannot be probed. |
curl "$GHOST_API/v1/runs/{runId}" \
-H 'X-Ghost-Api-Key: ghost_sk_…'
/v1/runs/{runId}/events
API keySession
Stream run events
Server-sent events for the streaming response mode. Emits the run's history from after_seq and then streams live until the run reaches a terminal state, at which point the server closes the connection.
Each SSE data: frame is one JSON-encoded RunEvent. Reconnect with after_seq set to the last seq you processed; events are durable and replayable, so no events are lost across a reconnect.
A terminal event is always emitted — complete, error, cancelled, or awaiting_approval. Clients should treat connection close without a terminal event as a transport failure and reconnect.
| Name | In | Type | Notes | |
|---|---|---|---|---|
runId |
path | string (uuid) | required | |
after_seq |
query | integer | optional | Resume after this sequence number. Omit to receive from the beginning. |
| Code | Body | |
|---|---|---|
| 200 | string |
An SSE stream of RunEvent frames. |
| 401 | Error |
Missing or invalid credential. |
| 404 | Error |
No such resource in this tenant. Also returned when a resource exists but belongs to another tenant, so existence cannot be probed. |
curl "$GHOST_API/v1/runs/{runId}/events" \
-H 'X-Ghost-Api-Key: ghost_sk_…'
/v1/runs/{runId}/approval
API keySession
Approve or deny a suspended run
Valid only while the run is awaiting_approval; any other status returns 409.
The body is {"decision": "approve"} or {"decision": "deny"}. There is no boolean form — {"approved": false} is a 422.
Approving executes the pending tool and resumes the loop. Denying feeds the refusal back to the model as the tool's result, so it can acknowledge and stop rather than silently retrying.
Returns the resumed run. Both decisions return it as queued in the default async mode, including deny: a denial still runs a turn, so the run is not terminal when this call returns and you must poll GET /v1/runs/{runId} or watch the event stream for the settled state. Use mode: "sync" to block until it settles instead.
ApprovalDecision
| Name | In | Type | Notes | |
|---|---|---|---|---|
runId |
path | string (uuid) | required | |
Idempotency-Key |
header | string | optional | Client-generated key making this request safe to retry. Replaying a key within 24 hours returns the original response instead of acting twice. Reusing a key with a different body returns 409. |
| Code | Body | |
|---|---|---|
| 200 | Run |
Decision applied; run resumed. Shape depends on mode. |
| 202 | Run |
Decision applied; resumed run is queued. |
| 400 | Error |
Malformed request. |
| 401 | Error |
Missing or invalid credential. |
| 402 | Error |
The tenant's allowance is exhausted. The circuit breaker is open. |
| 404 | Error |
No such resource in this tenant. Also returned when a resource exists but belongs to another tenant, so existence cannot be probed. |
| 409 | Error |
The resource is not in a state that permits this operation. |
| 422 | Error |
Well-formed but semantically invalid. details lists each failure. |
| 504 | Error |
mode: sync exceeded timeout_ms. The run was not cancelled and continues in the background; error.run_id identifies it. |
curl -X POST "$GHOST_API/v1/runs/{runId}/approval" \
-H 'X-Ghost-Api-Key: ghost_sk_…' \
-H 'Content-Type: application/json' \
-d '{ … }'
/v1/runs/{runId}/resume
API keySession
Resume a run suspended for a token refresh
Valid only while the run is awaiting_token_refresh; any other status returns 409. Use POST /v1/runs/{runId}/approval for the approval case.
Carry a fresh, unexpired TenantApiKey on the request. Ghost validates it, checks its tenant claim matches the run's tenant, and continues the loop from where it paused. The run's accumulated state, turn count, and usage are preserved — this is a continuation, not a retry.
There is no request body: the credential in the header is the entire input.
| Name | In | Type | Notes | |
|---|---|---|---|---|
runId |
path | string (uuid) | required | |
Idempotency-Key |
header | string | optional | Client-generated key making this request safe to retry. Replaying a key within 24 hours returns the original response instead of acting twice. Reusing a key with a different body returns 409. |
| Code | Body | |
|---|---|---|
| 200 | Run |
Run resumed and reached a terminal state (sync semantics). |
| 202 | Run |
Run resumed and is queued. |
| 401 | Error |
Missing or invalid credential. |
| 402 | Error |
The tenant's allowance is exhausted. The circuit breaker is open. |
| 404 | Error |
No such resource in this tenant. Also returned when a resource exists but belongs to another tenant, so existence cannot be probed. |
| 409 | Error |
The resource is not in a state that permits this operation. |
curl -X POST "$GHOST_API/v1/runs/{runId}/resume" \
-H 'X-Ghost-Api-Key: ghost_sk_…'
/v1/runs/{runId}/cancel
API keySession
Cancel a run
Requests cancellation of a queued, running, or suspended run. Cancellation is cooperative: an in-flight tool call is allowed to finish, so the run may take a moment to reach cancelled. Work already performed is still metered.
Cancelling an already-terminal run returns 409.
| Name | In | Type | Notes | |
|---|---|---|---|---|
runId |
path | string (uuid) | required |
| Code | Body | |
|---|---|---|
| 202 | Run |
Cancellation requested. |
| 401 | Error |
Missing or invalid credential. |
| 404 | Error |
No such resource in this tenant. Also returned when a resource exists but belongs to another tenant, so existence cannot be probed. |
| 409 | Error |
The resource is not in a state that permits this operation. |
curl -X POST "$GHOST_API/v1/runs/{runId}/cancel" \
-H 'X-Ghost-Api-Key: ghost_sk_…'
Tools
Tool registry and direct invocation.
/v1/agents/{agentId}/tools
API keySession
List tools this agent may call
The effective allowlist: the intersection of the tenant's enabled tools and this agent's grants. This is the authoritative answer to "can this agent call X" and is enforced at dispatch, not only at prompt assembly.
Grants only. Every entry here is granted, so enabled is always true. The tenant's full catalogue — what you *could* grant — is GET /v1/tools. Build a PUT body from this endpoint, never from the catalogue: the PUT is a full replacement, so a body built by unioning catalogue names grants that agent everything the tenant has, including other integrator workspaces' tools.
Not paged. next_cursor is present and always null so one pagination loop is correct against every list endpoint in this API.
| Name | In | Type | Notes | |
|---|---|---|---|---|
agentId |
path | string (uuid) | required |
| Code | Body | |
|---|---|---|
| 200 | ToolList |
Tools available to this agent. |
| 401 | Error |
Missing or invalid credential. |
| 404 | Error |
No such resource in this tenant. Also returned when a resource exists but belongs to another tenant, so existence cannot be probed. |
curl "$GHOST_API/v1/agents/{agentId}/tools" \
-H 'X-Ghost-Api-Key: ghost_sk_…'
/v1/agents/{agentId}/tools
API keySession
Replace this agent's tool allowlist
Full replacement, not a merge. Names not present in the tenant's tool registry are rejected with 422.
Replacement means an omitted tool is revoked, including the agent's built-ins, and the call returns 200 either way. To add one tool, read GET /v1/agents/{agentId}/tools, append, and send the whole list back.
The response is the same grants-only shape the GET returns, so "what is on now" is one document whichever verb asked. It may contain more than you sent: Composio grants are per toolkit, so naming one tool of a toolkit grants the toolkit, and the response shows that rather than hiding it.
AgentToolAllowlist
| Name | In | Type | Notes | |
|---|---|---|---|---|
agentId |
path | string (uuid) | required |
| Code | Body | |
|---|---|---|
| 200 | ToolList |
Updated allowlist. |
| 400 | Error |
Malformed request. |
| 401 | Error |
Missing or invalid credential. |
| 404 | Error |
No such resource in this tenant. Also returned when a resource exists but belongs to another tenant, so existence cannot be probed. |
| 422 | Error |
Well-formed but semantically invalid. details lists each failure. |
curl -X PUT "$GHOST_API/v1/agents/{agentId}/tools" \
-H 'X-Ghost-Api-Key: ghost_sk_…' \
-H 'Content-Type: application/json' \
-d '{ … }'
/v1/tools
API keySession
List tools available to this tenant
The tenant's tool registry. Every descriptor declares its owner (who defined it) and executor (what runs it) separately, so callers do not infer routing from naming conventions.
| Name | In | Type | Notes | |
|---|---|---|---|---|
limit |
query | integer | optional | Page size. |
cursor |
query | string | optional | Opaque cursor from a previous response's next_cursor. |
owner_kind |
query | "core" | "composio" | "mcp" | "external" | optional |
| Code | Body | |
|---|---|---|
| 200 | ToolList |
A page of tool descriptors. |
| 401 | Error |
Missing or invalid credential. |
curl "$GHOST_API/v1/tools" \ -H 'X-Ghost-Api-Key: ghost_sk_…'
/v1/tools/{toolName}
API keySession
Get a tool descriptor
| Name | In | Type | Notes | |
|---|---|---|---|---|
toolName |
path | string | required |
| Code | Body | |
|---|---|---|
| 200 | Tool |
The tool descriptor. |
| 401 | Error |
Missing or invalid credential. |
| 404 | Error |
No such resource in this tenant. Also returned when a resource exists but belongs to another tenant, so existence cannot be probed. |
curl "$GHOST_API/v1/tools/{toolName}" \
-H 'X-Ghost-Api-Key: ghost_sk_…'
/v1/tools/{toolName}/invocations
API keySession
Invoke a tool directly
Runs one tool outside any agent loop, using the calling tenant's credentials. Useful for testing a connection and for products that want Ghost's tool layer without its reasoning layer.
The tool must be on the tenant's allowlist, and on the agent's allowlist when agent_id is supplied. Direct invocation is metered like any other tool call.
ToolInvocationCreate
| Name | In | Type | Notes | |
|---|---|---|---|---|
toolName |
path | string | required | |
Idempotency-Key |
header | string | optional | Client-generated key making this request safe to retry. Replaying a key within 24 hours returns the original response instead of acting twice. Reusing a key with a different body returns 409. |
| Code | Body | |
|---|---|---|
| 200 | ToolInvocation |
The tool ran. A tool that failed on its own terms still returns 200 with ok: false — the HTTP status describes the invocation, not the tool's verdict. |
| 400 | Error |
Malformed request. |
| 401 | Error |
Missing or invalid credential. |
| 402 | Error |
The tenant's allowance is exhausted. The circuit breaker is open. |
| 403 | Error |
Tool exists but is not on the allowlist for this tenant or agent. |
| 404 | Error |
No such resource in this tenant. Also returned when a resource exists but belongs to another tenant, so existence cannot be probed. |
| 422 | Error |
Well-formed but semantically invalid. details lists each failure. |
| 429 | Error |
Too many requests. |
curl -X POST "$GHOST_API/v1/tools/{toolName}/invocations" \
-H 'X-Ghost-Api-Key: ghost_sk_…' \
-H 'Content-Type: application/json' \
-d '{ … }'
MCP servers
Tool sources the tenant owns and points Ghost at — their CRM, their ticketing system, their internal service. Ghost caches each server's tool catalogue and offers those tools to the agents the tenant grants them to.
This is how a tenant gives an agent access to exactly what it needs without Ghost building an integration per customer.
/v1/mcp-servers
API keySession
List MCP servers
| Code | Body | |
|---|---|---|
| 200 | object |
Every MCP server registered by this tenant. |
| 401 | Error |
Missing or invalid credential. |
curl "$GHOST_API/v1/mcp-servers" \ -H 'X-Ghost-Api-Key: ghost_sk_…'
/v1/mcp-servers
API keySession
Register an MCP server
Registers a streamable-HTTP MCP endpoint and reads its tool catalogue immediately, so a bad URL or credential is reported now rather than when an agent first tries to use a tool.
A sync failure does not fail this request. The registration is real either way and you need its id to retry against — check status and last_error on the response.
url and slug are permanent. Neither can be changed after this call — PATCH /v1/mcp-servers/{serverId} takes the credential and the credential policy only. Changing either means deleting the registration and creating a new one, which revokes every per-agent grant against it and invalidates every session credential keyed on the old slug.
Choose both as if you will never change them, because you will not:
- a hostname you control and expect to keep, not one that embeds a platform or project identifier you might migrate off; - a slug that names the *service*, not whoever registered it first. Ghost signs it as the aud claim of the Ghost-Caller-Identity header, and it is half of every tool name your agents see (mcp__<slug>__<tool>).
This matters more the fewer registrations you have. An integrator sharing one registration across their customers is making this choice exactly once, for everything.
auth_token is write-only. It is never returned by any endpoint; auth reports only whether one is stored.
McpServerCreate
| Name | In | Type | Notes | |
|---|---|---|---|---|
Idempotency-Key |
header | string | optional | Client-generated key making this request safe to retry. Replaying a key within 24 hours returns the original response instead of acting twice. Reusing a key with a different body returns 409. |
| Code | Body | |
|---|---|---|
| 201 | McpServer |
The registered server. |
| 401 | Error |
Missing or invalid credential. |
| 409 | Error |
A server with that slug already exists in this tenant. |
| 422 | Error |
Well-formed but semantically invalid. details lists each failure. |
curl -X POST "$GHOST_API/v1/mcp-servers" \
-H 'X-Ghost-Api-Key: ghost_sk_…' \
-H 'Content-Type: application/json' \
-d '{ … }'
/v1/mcp-servers/{serverId}
API keySession
Get an MCP server
| Name | In | Type | Notes | |
|---|---|---|---|---|
serverId |
path | string (uuid) | required |
| Code | Body | |
|---|---|---|
| 200 | McpServer |
The server. |
| 401 | Error |
Missing or invalid credential. |
| 404 | Error |
No such resource in this tenant. Also returned when a resource exists but belongs to another tenant, so existence cannot be probed. |
curl "$GHOST_API/v1/mcp-servers/{serverId}" \
-H 'X-Ghost-Api-Key: ghost_sk_…'
/v1/mcp-servers/{serverId}
API keySession
Update an MCP server's credential or credential policy
McpServerUpdate
| Name | In | Type | Notes | |
|---|---|---|---|---|
serverId |
path | string (uuid) | required |
| Code | Body | |
|---|---|---|
| 200 | McpServer |
The updated server. |
| 401 | Error |
Missing or invalid credential. |
| 404 | Error |
No such resource in this tenant. Also returned when a resource exists but belongs to another tenant, so existence cannot be probed. |
| 422 | Error |
Well-formed but semantically invalid. details lists each failure. |
curl -X PATCH "$GHOST_API/v1/mcp-servers/{serverId}" \
-H 'X-Ghost-Api-Key: ghost_sk_…' \
-H 'Content-Type: application/json' \
-d '{ … }'
/v1/mcp-servers/{serverId}
API keySession
Remove an MCP server
Cached tools and per-agent grants are removed with it. A run already in flight is unaffected — the tool list offered when a run started is that run's allowlist.
| Name | In | Type | Notes | |
|---|---|---|---|---|
serverId |
path | string (uuid) | required |
| Code | Body | |
|---|---|---|
| 204 | — | Removed. |
| 401 | Error |
Missing or invalid credential. |
| 404 | Error |
No such resource in this tenant. Also returned when a resource exists but belongs to another tenant, so existence cannot be probed. |
curl -X DELETE "$GHOST_API/v1/mcp-servers/{serverId}" \
-H 'X-Ghost-Api-Key: ghost_sk_…'
/v1/mcp-servers/{serverId}/tools
API keySession
List a server's cached tools
What Ghost read from the server on its last successful sync. The loop reads this cache rather than calling tools/list every turn, so it can be stale — POST .../refresh is how you say the catalogue changed.
| Name | In | Type | Notes | |
|---|---|---|---|---|
serverId |
path | string (uuid) | required |
| Code | Body | |
|---|---|---|
| 200 | object |
The cached catalogue. |
| 401 | Error |
Missing or invalid credential. |
| 404 | Error |
No such resource in this tenant. Also returned when a resource exists but belongs to another tenant, so existence cannot be probed. |
curl "$GHOST_API/v1/mcp-servers/{serverId}/tools" \
-H 'X-Ghost-Api-Key: ghost_sk_…'
/v1/mcp-servers/{serverId}/refresh
API keySession
Re-read a server's tool catalogue
| Name | In | Type | Notes | |
|---|---|---|---|---|
serverId |
path | string (uuid) | required | |
Idempotency-Key |
header | string | optional | Client-generated key making this request safe to retry. Replaying a key within 24 hours returns the original response instead of acting twice. Reusing a key with a different body returns 409. |
| Code | Body | |
|---|---|---|
| 200 | McpServer |
The server, with its refreshed tool count. |
| 400 | Error |
The server could not be read. The message is the failure your endpoint returned, not ours. |
| 401 | Error |
Missing or invalid credential. |
| 404 | Error |
No such resource in this tenant. Also returned when a resource exists but belongs to another tenant, so existence cannot be probed. |
curl -X POST "$GHOST_API/v1/mcp-servers/{serverId}/refresh" \
-H 'X-Ghost-Api-Key: ghost_sk_…'
Usage
Per-tenant metering.
/v1/usage
API keySession
Get metered usage
Per-tenant accounting over a time window. Covers tokens, wall-clock seconds, and billable actions. When the tenant's allowance is exhausted, cost-bearing endpoints return 402 and in-flight runs are suspended by the circuit breaker.
| Name | In | Type | Notes | |
|---|---|---|---|---|
from |
query | string (date-time) | optional | Inclusive start. Defaults to the start of the current billing period. |
to |
query | string (date-time) | optional | Exclusive end. Defaults to now. |
group_by |
query | "total" | "agent" | "day" | optional |
| Code | Body | |
|---|---|---|
| 200 | UsageReport |
Usage for the window. |
| 400 | Error |
Malformed request. |
| 401 | Error |
Missing or invalid credential. |
curl "$GHOST_API/v1/usage" \ -H 'X-Ghost-Api-Key: ghost_sk_…'
Console
Self-service for a signed-in operator, used by the tenant console. Every route derives its tenant from the session and names none, so there is no path parameter that could point one tenant at another's data.
/v1/me/keys
Session
List your own tenant's API keys
Metadata only — nothing here can reconstruct a key. Revoked keys are included so an operator can see what once existed.
| Code | Body | |
|---|---|---|
| 200 | object |
Your tenant's keys. |
| 401 | Error |
Missing or invalid credential. |
| 403 | Error |
Authenticated, but this credential type may not perform this operation. Note that cross-tenant resource access returns 404, not 403. |
| 404 | Error |
No such resource in this tenant. Also returned when a resource exists but belongs to another tenant, so existence cannot be probed. |
curl "$GHOST_API/v1/me/keys" \ -H 'Authorization: Bearer <session-jwt>'
/v1/me/keys
Session
Mint an API key for your own tenant
The self-service counterpart to POST /v1/tenants/{tenantId}/keys. Same result, different credential: this one is authorised by an operator's signed-in session rather than by the control plane, and it takes no tenant id — the tenant comes from the session.
A UserJwt only. Deliberately NOT callable with a TenantApiKey: the reason key management is privileged is that a *stolen key* must not be able to issue itself a replacement and outlive the revocation of the key that was noticed. A session is a different credential class, so allowing it here changes nothing about that threat — allowing a tenant key would reopen it.
As with the control-plane route, the plaintext appears in this response and nowhere else.
ApiKeyCreate
| Name | In | Type | Notes | |
|---|---|---|---|---|
Idempotency-Key |
header | string | optional | Client-generated key making this request safe to retry. Replaying a key within 24 hours returns the original response instead of acting twice. Reusing a key with a different body returns 409. |
| Code | Body | |
|---|---|---|
| 201 | ApiKeyWithSecret |
Key created. The plaintext is in this response only. |
| 401 | Error |
Missing or invalid credential. |
| 403 | Error |
Authenticated, but this credential type may not perform this operation. Note that cross-tenant resource access returns 404, not 403. |
| 404 | Error |
No such resource in this tenant. Also returned when a resource exists but belongs to another tenant, so existence cannot be probed. |
| 422 | Error |
Well-formed but semantically invalid. details lists each failure. |
curl -X POST "$GHOST_API/v1/me/keys" \
-H 'Authorization: Bearer <session-jwt>' \
-H 'Content-Type: application/json' \
-d '{ … }'
/v1/me/keys/{keyId}
Session
Revoke one of your own API keys
Takes effect on the next request. Idempotent: revoking an already-revoked key reports it as it stands rather than erroring.
A key belonging to another tenant reports 404 rather than 403, so the endpoint cannot be used to discover which key ids exist.
| Name | In | Type | Notes | |
|---|---|---|---|---|
keyId |
path | string (uuid) | required |
| Code | Body | |
|---|---|---|
| 200 | ApiKey |
The revoked key. |
| 401 | Error |
Missing or invalid credential. |
| 403 | Error |
Authenticated, but this credential type may not perform this operation. Note that cross-tenant resource access returns 404, not 403. |
| 404 | Error |
No such resource in this tenant. Also returned when a resource exists but belongs to another tenant, so existence cannot be probed. |
curl -X DELETE "$GHOST_API/v1/me/keys/{keyId}" \
-H 'Authorization: Bearer <session-jwt>'
/v1/me/billing
Session
Balance, credit packs, and payment history
Everything the console's billing view needs in one read: the current balance, whether the spend breaker is open, what can be bought, and what has been paid for so far.
mode reports which Stripe account this deployment is wired to, or null when none is configured. A client should treat null as "purchasing is unavailable here" rather than offering a button that cannot work.
A UserJwt only. Purchasing is an act by a person, and a credential that could buy credits would turn a leaked key into a bill.
| Code | Body | |
|---|---|---|
| 200 | BillingOverview |
Billing overview for your tenant. |
| 401 | Error |
Missing or invalid credential. |
| 403 | Error |
Authenticated, but this credential type may not perform this operation. Note that cross-tenant resource access returns 404, not 403. |
| 404 | Error |
No such resource in this tenant. Also returned when a resource exists but belongs to another tenant, so existence cannot be probed. |
curl "$GHOST_API/v1/me/billing" \ -H 'Authorization: Bearer <session-jwt>'
/v1/me/billing/checkout
Session
Start a credit purchase
Creates a Stripe Checkout Session for one credit pack and returns the URL to send the buyer to.
This does not grant credits. It returns a place to pay. Credits are granted only when Stripe reports the payment settled, which arrives as a settlement callback and may be after the buyer is already back on this site. A client should re-read GET /v1/me/billing on return rather than assuming the balance has moved.
The card is saved against the tenant's Stripe customer for later off-session use. Nothing charges it today.
Where the buyer is returned to is derived from the Origin of this request and checked against Ghost's allowlist — it is not a parameter, so there is no redirect target a caller can choose.
CheckoutCreate
| Name | In | Type | Notes | |
|---|---|---|---|---|
Idempotency-Key |
header | string | optional | Client-generated key making this request safe to retry. Replaying a key within 24 hours returns the original response instead of acting twice. Reusing a key with a different body returns 409. |
| Code | Body | |
|---|---|---|
| 201 | CheckoutSession |
A checkout session. Send the buyer to url. |
| 400 | Error |
Malformed request. |
| 401 | Error |
Missing or invalid credential. |
| 403 | Error |
Authenticated, but this credential type may not perform this operation. Note that cross-tenant resource access returns 404, not 403. |
| 404 | Error |
No such resource in this tenant. Also returned when a resource exists but belongs to another tenant, so existence cannot be probed. |
| 422 | Error |
Well-formed but semantically invalid. details lists each failure. |
curl -X POST "$GHOST_API/v1/me/billing/checkout" \
-H 'Authorization: Bearer <session-jwt>' \
-H 'Content-Type: application/json' \
-d '{ … }'
/v1/logs
API keySession
List API requests
The HTTP exchange log for your tenant, newest first. Distinct from /v1/runs, which reports what agents did: this reports what your integration *sent*, including the calls that never became a run because they were rejected. A 401 from a stale key or a 422 from a malformed body appears here and nowhere else.
Retained for retention_days (30). Older rows are pruned nightly.
Readable with either credential. A tenant key may read its own traffic — that discloses nothing the key does not already have.
| Name | In | Type | Notes | |
|---|---|---|---|---|
limit |
query | integer | optional | |
cursor |
query | string | optional | Opaque cursor from next_cursor. |
filter |
query | "errors" | optional | errors narrows to responses with status >= 400. Any other value is rejected rather than ignored — a filter that silently does nothing is worse than no filter. |
request_id |
query | string | optional | Exact match on the id echoed in X-Request-Id and in every error envelope, for tracing one reported call. |
caller |
query | "api" | "console" | "all" | optional | Which credential made the call. api is your integration's traffic (an API key); console is the signed-in console reading this page, which polls from every screen and would otherwise drown out the calls you came here to look at. all returns both. |
| Code | Body | |
|---|---|---|
| 200 | object |
A page of requests. |
| 400 | Error |
Malformed request. |
| 401 | Error |
Missing or invalid credential. |
curl "$GHOST_API/v1/logs" \ -H 'X-Ghost-Api-Key: ghost_sk_…'
/v1/logs/summary
API keySession
Summarise recent API requests
Totals over a window, for a console header. Computed over the most recent 5000 calls in the window so one busy tenant cannot make this an unbounded scan.
| Name | In | Type | Notes | |
|---|---|---|---|---|
hours |
query | integer | optional | |
caller |
query | "api" | "console" | "all" | optional | As on /v1/logs. Pass the same value to both, or the summary will count traffic the list below it is hiding. |
| Code | Body | |
|---|---|---|
| 200 | object |
Summary for the window. |
| 400 | Error |
Malformed request. |
| 401 | Error |
Missing or invalid credential. |
curl "$GHOST_API/v1/logs/summary" \ -H 'X-Ghost-Api-Key: ghost_sk_…'
Schemas
The shapes referenced above.
ErrorTypeStable, machine-readable failure class. Treat unknown members as a generic failure of the response's HTTP status class.
Error| Field | Type | Notes | |
|---|---|---|---|
error |
object | required |
Liveness| Field | Type | Notes | |
|---|---|---|---|
status |
string | required | |
version |
string | optional |
Readiness| Field | Type | Notes | |
|---|---|---|---|
status |
"ok" | "degraded" | required | |
version |
string | optional | |
dependencies |
object[] | required |
Page| Field | Type | Notes | |
|---|---|---|---|
next_cursor |
string,null | required | Pass as cursor for the next page. null on the last page. |
Tenant| Field | Type | Notes | |
|---|---|---|---|
id |
string (uuid) | required | |
name |
string | required | |
status |
"active" | "suspended" | required | suspended means the circuit breaker is open. Cost-bearing operations return 402 until the allowance is restored. |
metadata |
object | optional | Opaque caller-defined data. Ghost never interprets this. |
approval_webhook_url |
string,null (uri) | optional | Where Ghost posts a callback when one of this tenant's runs suspends at awaiting_approval. null means delivery is off. |
created_at |
string (date-time) | required | |
updated_at |
string (date-time) | required |
TenantUpdatedTenantCreate| Field | Type | Notes | |
|---|---|---|---|
name |
string | required | |
metadata |
object | optional | |
retention_days |
integer,null | optional | Session retention window in days. Omit or null to keep sessions indefinitely. |
TenantUpdate| Field | Type | Notes | |
|---|---|---|---|
name |
string | optional | |
metadata |
object | optional | |
retention_days |
integer,null | optional | Session retention window in days. null turns retention off and keeps sessions indefinitely. |
approval_webhook_url |
string,null (uri) | optional | An absolute https URL. Must resolve to a public address; a private or unresolvable host is refused here rather than at delivery time.
Setting a URL that differs from the current one mints a fresh signing secret and returns it once as To rotate a secret you have lost, set the URL to |
TenantListAgentKindstandard agents do work. A meta agent orchestrates other agents in its tenant. Exactly one meta agent exists per tenant and it is created automatically.
AgentStatusAgent| Field | Type | Notes | |
|---|---|---|---|
id |
string (uuid) | required | |
tenant_id |
string (uuid) | required | |
kind |
AgentKind | required | |
name |
string | required | |
role |
string,null | optional | |
instructions |
string,null | optional | Operator-authored system instructions for this agent. |
status |
AgentStatus | required | |
timezone |
string | optional | |
require_approval_for |
ToolName[] | optional | Tools this agent stops and asks about before running. A run that attempts one suspends as awaiting_approval with a pending_approval payload naming the tool and its arguments; resolve it with POST /v1/runs/{runId}/approval.
Set on the agent, not per message. A policy a caller has to remember to resend on every request is one that eventually is not resent, and the failure is silent — the gated tool simply runs. Empty means nothing is gated, and it is the default. That default is worth a second look before you ship: an agent granted A trailing |
metadata |
object | optional | |
created_at |
string (date-time) | required | |
updated_at |
string (date-time) | required |
AgentCreate| Field | Type | Notes | |
|---|---|---|---|
name |
string | required | |
role |
string,null | optional | |
instructions |
string,null | optional | |
status |
object | optional | |
timezone |
string | optional | |
require_approval_for |
ToolName[] | optional | Tools this agent stops and asks about before running. A run that attempts one suspends as awaiting_approval with a pending_approval payload naming the tool and its arguments; resolve it with POST /v1/runs/{runId}/approval.
Set on the agent, not per message. A policy a caller has to remember to resend on every request is one that eventually is not resent, and the failure is silent — the gated tool simply runs. Empty means nothing is gated, and it is the default. That default is worth a second look before you ship: an agent granted A trailing |
tools |
ToolName[] | optional | Initial tool allowlist. Defaults to empty. |
metadata |
object | optional |
AgentUpdate| Field | Type | Notes | |
|---|---|---|---|
name |
string | optional | |
role |
string,null | optional | |
instructions |
string,null | optional | |
status |
AgentStatus | optional | |
timezone |
string | optional | |
require_approval_for |
ToolName[] | optional | Tools this agent stops and asks about before running. A run that attempts one suspends as awaiting_approval with a pending_approval payload naming the tool and its arguments; resolve it with POST /v1/runs/{runId}/approval.
Set on the agent, not per message. A policy a caller has to remember to resend on every request is one that eventually is not resent, and the failure is silent — the gated tool simply runs. Empty means nothing is gated, and it is the default. That default is worth a second look before you ship: an agent granted A trailing |
metadata |
object | optional |
AgentListAgentToolAllowlist| Field | Type | Notes | |
|---|---|---|---|
tools |
ToolName[] | required |
AgentAsk| Field | Type | Notes | |
|---|---|---|---|
input |
string | required | What to ask the agent. Treated as data, like message content. |
session_id |
string (uuid) | optional | Continue this session. Must belong to this agent. Omitted opens a fresh session for the agent. |
mode |
"async" | "sync" | "stream" | optional | async queues and returns the run. sync blocks until it settles. stream returns the run's SSE event stream directly. |
timeout_ms |
integer | optional | Applies to sync only. Exceeding it returns 504. |
ephemeral |
boolean | optional | Drive this run without writing the input into the transcript. |
title |
string | optional | Title for the fresh session when session_id is omitted. |
context |
SessionContextItem[] | optional | Session context for the fresh session when session_id is omitted. |
WorkflowStepSummary| Field | Type | Notes | |
|---|---|---|---|
id |
string | required | |
label |
string | required |
WorkflowSummary| Field | Type | Notes | |
|---|---|---|---|
workflow_id |
string | required | |
message_id |
string (uuid) | required | |
agent_id |
string (uuid) | required | |
title |
string | required | |
status |
string | required | |
version |
integer | required | |
step_count |
integer | required | |
steps |
WorkflowStepSummary[] | required | |
archived |
boolean | required | |
updated_at |
string (date-time) | required |
WorkflowListWorkflowSessionStatusSession| Field | Type | Notes | |
|---|---|---|---|
id |
string (uuid) | required | |
tenant_id |
string (uuid) | required | |
agent_id |
string (uuid) | required | |
title |
string,null | optional | |
status |
SessionStatus | required | |
active_run_id |
string,null (uuid) | optional | The run currently occupying this session, if any. Non-null means posting a message will return 409. |
last_message_at |
string,null (date-time) | optional | |
message_count |
integer,null | optional | Messages in this session's transcript right now.
This is the pre-flight half of
|
history_window |
integer,null | optional | How many of those would be replayed into the next run — your tenant's window, or 40. message_count above this means the next run starts already truncated. |
metadata |
object | optional | |
created_at |
string (date-time) | required | |
updated_at |
string (date-time) | required |
SessionCreate| Field | Type | Notes | |
|---|---|---|---|
agent_id |
string (uuid) | required | |
title |
string,null | optional | |
context |
SessionContextItem[] | optional | Facts the caller wants available to the agent for this session, and only this session. Ghost uses them but does not store them as memory, so the caller stays the single owner of each fact and the two systems cannot drift.
Use this for records the caller is authoritative for — a CRM contact, an account state, a campaign brief. Content is treated as data throughout, exactly like message content. |
mcp_credentials |
object | optional | Bearer tokens this session presents to your MCP servers, keyed by the server's slug. Supplying one overrides the token stored on the registration for the life of this session; omitting it uses the registration's own token, which is the existing behaviour.
This exists for callers who are themselves multi-tenant. Without it a credential is bound statically to a registration, so the token is the only scope, so isolating N customers means N registrations and — because grants are per agent — N agents inside one Ghost tenant. None of those objects represent anything in your product, and every one is a place isolation can silently fail. With it, one registration and one agent cover every customer, and the blast radius of any one token is one customer. Ghost does not validate the token beyond checking the slug names a server you have registered — whether it is any good is your server's answer to give, at call time. An unrecognised slug is a 422 rather than an ignored key: silently dropping it would run the whole session against the registration's token instead, which is the substitution this field exists to prevent. Write-only. No response returns it. Held for the life of the session and deleted with it. |
metadata |
object | optional |
SessionContextItem| Field | Type | Notes | |
|---|---|---|---|
label |
string | required | Short name for this fact, e.g. contact or account_status. |
content |
string | required | |
provenance |
object | optional |
SessionUpdate| Field | Type | Notes | |
|---|---|---|---|
title |
string,null | optional | |
status |
SessionStatus | optional | |
metadata |
object | optional |
SessionListMessageRoleuser is inbound. agent is the substantive reply. card is a structured payload the client renders as a widget rather than prose.
MessageProvenanceKindWhere this content came from. Anything other than external_user is machine-originated. Content is wrapped so the model reads it as data regardless of value.
Message| Field | Type | Notes | |
|---|---|---|---|
id |
string (uuid) | required | |
session_id |
string (uuid) | required | |
role |
MessageRole | required | |
content |
string | required | Plain text for user and agent. For card, a JSON document whose shape is given by card_kind. |
card_kind |
string,null | optional | Discriminator for card messages. Null otherwise. |
provenance |
MessageProvenanceKind | optional | |
run_id |
string,null (uuid) | optional | The run that produced this message, for agent output. |
created_at |
string (date-time) | required |
MessageCreate| Field | Type | Notes | |
|---|---|---|---|
content |
string | required | Inbound content. Treated as data. Instruction-shaped text here cannot change the agent's tool allowlist or its system instructions. |
mode |
"async" | "sync" | "record" | optional | async returns immediately with a queued run. sync blocks until the run reaches a terminal state.
Record is for keeping a session honest when something happened to the conversation outside Ghost. The case it exists for: a human on your side takes the thread over outside Ghost, says three things the agent never sees, and hands it back. Without recording those, the agent resumes knowing none of it and contradicts the person who just spoke — it desynchronises at exactly the moment a human intervened. None of the ingress gates apply. An active run does not block a record (there is no loop to race, and mid-run is precisely when a human interrupts), a suspended tenant may still record (it spends nothing), and there is no concurrency ceiling to hit. A recorded message joins the replay window like any other, so it counts against Order matters, and getting it wrong fails silently. A run's transcript is fixed when it is created, so anything the agent must answer has to be recorded *before* the run that answers it. Reversed, the run is created against a transcript missing the message it exists to answer, and the agent replies to the previous turn: no error, plausible output, wrong reply. Record first, then post the run. |
role |
"user" | "agent" | optional | Who is speaking. record mode only — sending it with async or sync returns 422.
Rejected outside record mode on purpose: on a run-starting message the speaker is you by definition, and accepting the field there would let you believe you had put words in the agent's mouth when you had in fact just prompted it. |
ephemeral |
boolean | optional | Drive this run without writing the message into the session's transcript.
This exists because An ephemeral message still drives this run — it is appended as the final user turn — and never enters the transcript, so it also never consumes the replay window. The durable transcript is then only what actually passed between the customer and the agent. The trade: nothing records what this run was asked to do. If the run fails, the prompt is not in the session for you to read back. You hold it; we do not. This is an instruction, not the thing being answered. Used with A 422 with |
timeout_ms |
integer | optional | Applies to sync only. Exceeding it returns 504. |
provenance |
object | optional | Read-only in practice. external_user is the only accepted value and is the default; internal_system returns 422.
It was previously accepted and then discarded — the request succeeded and the thing you asked for did not happen. Both things it was reached for now exist: |
metadata |
object | optional |
MessageListApiKeyCreate| Field | Type | Notes | |
|---|---|---|---|
name |
string | required | Operator-facing label, so a tenant holding several keys can tell them apart without being able to see any of them. |
expires_at |
string (date-time) | optional | Optional, and must be in the future. When set, this is the credential horizon for every run the key starts. |
RequestLogEntryOne HTTP exchange against this API.
| Field | Type | Notes | |
|---|---|---|---|
id |
string | required | Opaque, monotonically increasing. A string rather than a number because the underlying column is a 64-bit integer, which a JavaScript caller would silently round past 2^53. |
request_id |
string | required | The id echoed in X-Request-Id and carried in every error envelope. Quote this when reporting a problem. |
method |
string | required | |
route |
string | required | The route template (/v1/sessions/{sessionId}), never the concrete path. Templates group; concrete paths would both explode cardinality and copy your resource ids into a log retained longer than they are. |
status |
integer | required | |
latency_ms |
integer | required | |
api_key_id |
string (uuid) | null | optional | Which key made the call. Null for a first-party session, and null once the key has been deleted — revoking a key does not erase what it did. |
principal_role |
"tenant" | "user" | "control" | null | optional | Null when the request failed before authenticating. |
error_type |
string | null | optional | The error envelope's type on failure, so failures can be grouped by cause without parsing messages. Null on success. |
created_at |
string (date-time) | required |
ApiKeyA key's metadata. Deliberately contains nothing from which the key itself could be reconstructed.
| Field | Type | Notes | |
|---|---|---|---|
id |
string (uuid) | required | |
tenant_id |
string (uuid) | required | |
name |
string | required | |
key_prefix |
string | required | The leading, non-secret portion — enough to identify a key in a list or a log line, nowhere near enough to use one. |
expires_at |
string (date-time) | null | optional | |
last_used_at |
string (date-time) | null | optional | Best-effort. Good for spotting an unused key, not a billing record. |
revoked_at |
string (date-time) | null | optional | Set rather than deleted, so an audit survives the revocation. |
created_at |
string (date-time) | required |
ApiKeyWithSecretCreditPackA fixed amount of credits at a fixed price. Packs rather than arbitrary amounts: there is no amount to validate, the ledger stays readable, and a client has something to render without inventing a currency input.
| Field | Type | Notes | |
|---|---|---|---|
id |
string | required | Pass this to createOwnCheckout. |
amount_cents |
integer | required | What is charged, in cents. |
credits |
integer | required | What lands in the wallet. Stated rather than derived from the price: the packs discount by volume, so credits and price are deliberately not proportional. |
label |
string | required |
PaymentRecordOne settled payment. Written when Stripe confirms money arrived, so a row here means credits were granted — not that a checkout was started.
| Field | Type | Notes | |
|---|---|---|---|
id |
string (uuid) | required | |
amount_cents |
integer | required | |
currency |
string | required | |
credits |
integer | required | What this payment bought, recorded at the time. A later change to the credit rate cannot retroactively restate an old payment. |
livemode |
boolean | required | False for a test-mode payment, which is real history but not real money. |
created_at |
string (date-time) | required |
BillingOverview| Field | Type | Notes | |
|---|---|---|---|
balance |
integer | required | Credits currently held. |
breaker_open |
boolean | required | True when the tenant is suspended and cost-bearing calls are returning 402. The same flag UsageReport.allowance reports. |
mode |
"test" | "live" | null | required | Which Stripe account this deployment is wired to. Null means purchasing is not configured here. |
packs |
CreditPack[] | required | |
payments |
PaymentRecord[] | required | The 20 most recent settled payments, newest first. |
CheckoutCreate| Field | Type | Notes | |
|---|---|---|---|
pack |
string | required | A CreditPack.id from getOwnBilling. |
CheckoutSession| Field | Type | Notes | |
|---|---|---|---|
url |
string (uri) | required | Stripe-hosted. Send the buyer here; do not embed it, and do not treat reaching it as a purchase. |
expires_at |
string (date-time) | null | required | When the session stops being usable. |
RunStatusTerminal states are succeeded, failed, and cancelled.
The two awaiting_* states are suspensions, not endings: the run holds its state indefinitely and survives process restarts. They are separate statuses so a caller can tell "waiting on a human" from "send me a fresh credential" without inspecting anything else.
RunStopReasonWhy the loop stopped. incomplete means the agent ran out of turns or stated an intention it never carried out — the output is honest about this rather than presenting a promise as a result.
PendingApprovalThe gated tool call a suspended run is waiting on.
| Field | Type | Notes | |
|---|---|---|---|
tool |
ToolName | required | |
arguments |
object | required | |
reason |
string | optional | Why approval is required. |
RunUsage| Field | Type | Notes | |
|---|---|---|---|
input_tokens |
integer | required | |
output_tokens |
integer | required | |
tool_calls |
integer | required | |
duration_ms |
integer | required | |
cost |
number,null | optional | Billable cost in the tenant's accounting unit. |
Run| Field | Type | Notes | |
|---|---|---|---|
id |
string (uuid) | required | |
tenant_id |
string (uuid) | required | |
session_id |
string (uuid) | required | |
agent_id |
string (uuid) | required | |
status |
RunStatus | required | |
stop_reason |
RunStopReason | null | optional | Set once the run reaches a terminal state. |
turns |
integer | optional | Loop turns consumed. |
turn_cap |
integer | optional | Hard ceiling for this run. |
output |
string,null | optional | The agent's final substantive text. Null while the run is non-terminal, and null when the run ended without producing prose. |
messages |
Message[] | optional | Messages this run wrote. Present in sync responses. |
pending_approval |
PendingApproval | null | optional | Present only while status is awaiting_approval. |
usage |
RunUsage | null | optional | |
history |
RunHistory | null | optional | How much of the session's transcript this run was given. Null for a run that replays none. |
error |
string,null | optional | Failure detail when status is failed. |
created_at |
string (date-time) | required | |
updated_at |
string (date-time) | required |
RunListRunEventTypeplan, action_start, action_done, and reflect narrate progress and are safe to render as an activity feed. output_delta carries streamed prose. complete, error, cancelled, awaiting_approval, and awaiting_token_refresh are terminal for the stream.
RunEvent| Field | Type | Notes | |
|---|---|---|---|
seq |
integer | required | Monotonic within a run. Use as the SSE resume cursor. |
run_id |
string (uuid) | required | |
type |
RunEventType | required | |
created_at |
string (date-time) | required | |
tool |
ToolName | null | optional | Set on action_start and action_done. |
label |
string,null | optional | Human-readable headline for this step. |
summary |
string,null | optional | Short result description on action_done and complete. |
ok |
boolean,null | optional | Whether the action succeeded. Set on action_done. |
delta |
string,null | optional | Text fragment on output_delta. |
pending_approval |
PendingApproval | null | optional | |
message |
string,null | optional | Failure detail on error. |
RunHistoryThe replay window this run ran under.
A session's transcript is replayed into every run on it, most recent first, up to a cap. The cap is a message count, not a token budget — so the real ceiling on a long conversation is how many messages it has, and a thread that runs for days quietly loses how it started.
That is what this block exists to stop being silent. An agent that has forgotten the beginning of a conversation is otherwise indistinguishable from one choosing to ignore it. Read truncated and re-seed deliberately — the durable fix is to open a fresh session carrying the summary you want kept as context[].
The window is fixed when the run is created, so a run suspended for a day still reports the window it actually used. Ask if you need it raised for your tenant; it is per-tenant configurable, the same way max_concurrent_runs is.
| Field | Type | Notes | |
|---|---|---|---|
window |
integer | required | How many prior messages were eligible to be replayed. 40 unless your tenant has been configured otherwise. |
prior_messages |
integer | required | Messages already in the session when this run was created, not counting the one that started it. |
truncated |
boolean | required | True when prior_messages exceeded window and the oldest were not replayed. The agent did not see them. |
recorded_after_ingress |
integer,null | optional | Messages written into the session after this run's transcript was fixed — so, things this run could not see however late it finished.
A run loads its history once, at the start. A record posted at T+3s cannot reach a run created at T+0: that run answers the customer without the three things a human just said, which is the desynchronisation Non-zero means the output is stale. For an autonomous loop that auto-sends, check this before sending: discard the reply and re-run, or hand it to a human. Messages the run wrote itself are not counted. Populated on One honest limit: it is counted when the response is built. A message recorded between that read and your send is not in it. Nothing on this side can close that gap; it is milliseconds rather than the whole run. |
ApprovalDecision| Field | Type | Notes | |
|---|---|---|---|
decision |
"approve" | "deny" | required | The whole input. deny is a decision, not an error — it feeds the refusal back to the model as the tool's result. There is no boolean form of this field; {"approved": false} is a 422. |
reason |
string | optional | Shown to the agent on denial so it can respond sensibly. Treated as data, not instruction. |
mode |
"async" | "sync" | optional | |
timeout_ms |
integer | optional |
ToolNameSnake_case for built-in tools. MCP tools are namespaced mcp__<server>__<tool>.
ToolOwnerKindWho defined this tool. core is built into Ghost, composio comes from a connected Composio toolkit, mcp from a registered MCP server, and external is declared by the calling product as an HTTP endpoint.
McpServerStatuspending — registered, catalogue not yet read. active — last sync succeeded; its tools are offerable. failed — last sync failed; see last_error. The registration is kept so you can fix the endpoint and refresh rather than re-register.
McpServer| Field | Type | Notes | |
|---|---|---|---|
id |
string (uuid) | required | |
tenant_id |
string (uuid) | required | |
slug |
string | required | Lowercase letters, digits and hyphens, unique within the tenant. Underscores are excluded deliberately: tools are offered to the model as mcp__<slug>__<tool>, and a slug containing __ would make that name ambiguous to parse back. |
name |
string | required | |
url |
string (uri) | required | |
auth |
"none" | "bearer" | required | Whether a bearer token is stored for this server. The token itself is never returned by any endpoint. |
require_session_credential |
boolean | optional | When true, auth describes the catalogue credential only: every session that can reach this server must supply its own token, and none of them use the stored one. |
status |
McpServerStatus | required | |
tool_count |
integer | required | Tools in the cached catalogue. |
last_error |
string,null | optional | Why the last sync failed, if it did. Read this rather than trusting the 201 on registration: a sync failure still creates the row, and it has to, because you need the id to retry against. |
last_synced_at |
string,null (date-time) | optional | Catalogues are re-read hourly, and on demand via POST /v1/mcp-servers/{serverId}/refresh. Use the on-demand call after you deploy a tool change rather than waiting for the sweep. |
created_at |
string (date-time) | required | |
updated_at |
string (date-time) | required |
McpServerCreate| Field | Type | Notes | |
|---|---|---|---|
name |
string | required | |
url |
string (uri) | required | The server's streamable-HTTP endpoint. Must be https — a plaintext URL would put your own bearer token on the wire, so it is refused rather than warned about.
Permanent. Not editable after registration; see the operation description. Prefer a hostname you own over one that embeds a platform or project identifier. |
auth_token |
string | optional | Bearer token presented to your server. Write-only: accepted here, never returned, never logged.
When |
require_session_credential |
boolean | optional | Refuse to fall back to auth_token for tool calls. Every session that can reach this server must carry its own bearer token in POST /v1/sessions, and a session that omits one is a 422 at session create rather than a failure later inside a run.
Set this if you are a multi-tenant integrator sharing one registration across your customers. Without it, one forgotten |
slug |
string | optional | Permanent, and worth setting explicitly rather than letting it be derived. Not editable after registration; see the operation description. It is half of every tool name your agents see (mcp__<slug>__<tool>) and the aud claim Ghost signs into Ghost-Caller-Identity, so it should name the service rather than whoever happened to register it first.
Derived from A slug that breaks the rule is a 422, not the 409 you get for a duplicate. The two failures sit next to each other and mean different things: 422 is "that is not a slug", 409 is "that slug is taken". |
McpServerUpdateOnly the credential and the credential policy. url and slug are deliberately not editable: a URL change repoints every existing grant at a different server while the tool names stay identical, and a slug change silently invalidates every session credential keyed on the old one. Both are a delete and a re-register, where the blast radius is visible.
| Field | Type | Notes | |
|---|---|---|---|
auth_token |
string,null | optional | Replace the stored token, or null to clear it. |
require_session_credential |
boolean | optional | Takes effect on the next tool call, not the next session. Sessions already open that supplied no credential start failing their MCP calls loudly rather than quietly continuing on the stored token — a grace period would be a window in which that token was still an identity. |
McpServerTool| Field | Type | Notes | |
|---|---|---|---|
name |
string | required | The tool's own name, as your server reports it. |
qualified_name |
string | required | How the tool is offered to the model — mcp__<slug>__<name>. Routing is by this name, so two servers can expose the same tool name without colliding. |
description |
string,null | optional | |
annotations |
object,null | optional | The MCP annotations your server declared for this tool — readOnlyHint, destructiveHint, and the rest. Null when it declared none.
This endpoint used to omit them while |
ToolExecutorKindWhat runs this tool. Deliberately separate from owner so routing is declared rather than inferred from the tool's name — a naming convention is not a routing table.
http posts the arguments to the URL the owner declared, carrying the caller's session token so the endpoint can authorize the tenant itself.
Tool| Field | Type | Notes | |
|---|---|---|---|
name |
ToolName | required | |
title |
string,null | optional | |
description |
string | required | |
input_schema |
object | required | JSON Schema for the tool's arguments. |
output_schema |
object,null | optional | |
owner |
object | required | |
executor |
object | required | |
requires_approval |
boolean | optional | When true, a run calling this tool suspends at awaiting_approval rather than executing it. |
enabled |
boolean | optional | Whether this tool is on the allowlist in the queried scope. |
annotations |
object,null | optional | The tool's own annotations, as its MCP server declared them — readOnlyHint, destructiveHint, idempotentHint, openWorldHint, title. Null for tools that declare none, and for every Composio and Ghost-native tool.
Null is not "declared safe". A server that says nothing has said nothing, and Ghost reports the difference rather than filling it in. |
ToolGrantWarningA grant that is on, not gated for approval, and declares that it writes.
Warned about, not refused: an ungated write is a legitimate configuration and Ghost refusing it would be substituting its judgment for yours. What it should not be is indistinguishable from a working integration, which is what it was.
| Field | Type | Notes | |
|---|---|---|---|
code |
"granted_ungated_write" | required | |
tool |
ToolName | required | |
severity |
"high" | "medium" | required | high when the tool declares destructiveHint: true, medium otherwise.
The trigger is |
message |
string | required |
ToolListToolInvocationCreate| Field | Type | Notes | |
|---|---|---|---|
arguments |
object | required | Must validate against the tool's input_schema. |
agent_id |
string,null (uuid) | optional | Run as this agent, applying its allowlist in addition to the tenant's. Omit to use the tenant allowlist alone. |
ToolInvocation| Field | Type | Notes | |
|---|---|---|---|
id |
string (uuid) | required | |
tool |
ToolName | required | |
ok |
boolean | required | Whether the tool itself succeeded. |
result |
object | optional | Tool output. Shape follows the tool's output_schema. |
error |
string,null | optional | Failure detail when ok is false. Never contains credentials. |
duration_ms |
integer | required | |
created_at |
string (date-time) | required |
UsageBucket| Field | Type | Notes | |
|---|---|---|---|
group_key |
string,null | optional | Agent id or ISO date, per group_by. Null for the total. |
input_tokens |
integer | required | |
output_tokens |
integer | required | |
tool_calls |
integer | required | |
runs |
integer | required | |
duration_ms |
integer | required | |
cost |
number,null | optional |
UsageReport| Field | Type | Notes | |
|---|---|---|---|
from |
string (date-time) | required | |
to |
string (date-time) | required | |
group_by |
"total" | "agent" | "day" | required | |
buckets |
UsageBucket[] | required | |
allowance |
object | required |