Skip to content
BuildStackHQHosting
All docs

Docs

HTTP API

Every endpoint, its authentication and its shape.

The dashboard, the CLI and the MCP server are three clients of one HTTP API. Nothing else reaches the database. Every endpoint is described once in packages/api-contracts (endpoints registry: method, path template, params, query, body, response, success status, auth mode and required permission), the NestJS controllers are decorated from those entries, apps/api/test/openapi-contracts.spec.ts fails when the OpenAPI document and the registry disagree in either direction, and packages/api-client is generated from the same registry. Add an endpoint by adding a registry entry; the controller, the drift test and the client follow.

OpenAPI is served at /openapi.json and Swagger UI at /docs outside production.

Authentication

Every request outside /health, /hooks and /v1/public carries Authorization: Bearer <token>.

Token Who How it resolves
Zitadel access token (JWT) Humans in the dashboard and the CLI Verified against the JWKS, then mapped to a Keel user by subject. Permissions come from the membership role (owner, admin, member, viewer).
API key keel_... CI pipelines, backends, the MCP server later Looked up by SHA-256 hash, refused when revoked or past its expiresAt, lastUsedAt refreshed at most once a minute. Permissions come from the key's scopes. The key belongs to one organization; a Keel-Organization header naming another one is 403 no_membership.
Run token The supervisor container only Accepted on /ingest/v1 routes only (the local development notes).

API key scopes and the permissions they grant:

Scope Permissions
agents:read agents.read
agents:write agents.write, agents.read
runs:read runs.read
runs:trigger runs.trigger, runs.read
secrets:write secrets.write, secrets.read_names
secrets:read_names secrets.read_names
data_stores:read data_stores.read
data_stores:write data_stores.write, data_stores.read
builds:write builds.write, agents.read (repository connections, source bindings, builds, images, push credentials; no agent or trigger changes)
callbacks:manage callbacks.manage, runs.read (delivery history and redelivery; cannot trigger runs)
sites:read sites.read
sites:write sites.write, sites.read (create sites, upload, activate and roll back deployments, change settings, archive)

Roles map to permissions as follows: owner has everything; admin has everything except org.manage and billing.manage; member has agents.*, runs.*, secrets.*, sites.*, builds.write, callbacks.manage, data_stores.read and data_stores.write; viewer has agents.read, runs.read, sites.read, secrets.read_names and data_stores.read. data_stores.connect (revealing a connection URL) and secrets.reveal (exporting an agent's environment with its secret values) belong to owners and admins only and no API key scope grants them.

No scope grants org.manage, members.manage, billing.manage or api_keys.manage: keys cannot manage the organization, its members, its signing secrets or other keys. Those stay with signed-in owners and admins.

Clients discover the identity provider without configuration: GET /v1/auth/config is public and answers { issuer, projectId, cliClientId, dashboardClientId, mcpClientId } from AUTH_ISSUER_URL, AUTH_AUDIENCE and the AUTH_*_CLIENT_ID variables (a client id is null until its variable is set).

Organization context

Organization scoped routes need a tenant. Keel-Organization: <organization id or slug> selects it. A user who belongs to exactly one organization can omit the header; one who belongs to several gets 400 organization_required with the candidates. API keys ignore the header unless it names their own organization. GET /v1/organizations and POST /v1/organizations work without a tenant.

Conventions

  • JSON in and out. Dates are ISO 8601 strings in UTC.
  • Path ids are UUIDs; Keel-Organization also accepts the slug.
  • Every response carries x-request-id; send your own ([A-Za-z0-9._:-]{1,128}) to correlate.
  • Bodies are validated with the contract schemas; a mismatch is 400 validation_failed with errors: [{ path, message }].
  • Request bodies are limited to 1 MiB (413 body_too_large).

Pagination

List endpoints take limit (default 20, max 100; steps default 100, max 500; audit log default 50, max 200) and cursor, and answer { items, nextCursor }. nextCursor is opaque; pass it back as cursor until it is null. A cursor that was not produced by Keel answers 400 invalid_cursor. The client's paginate(client.runs.list, { query }) async iterator does this for you.

Problems

Errors are application/problem+json (RFC 9457):

{
  "type": "urn:keel:problem:callbacks_not_in_plan",
  "title": "Feature not in plan",
  "status": 409,
  "detail": "the current plan does not include outbound callbacks",
  "instance": "/v1/agents/8f3a.../runs",
  "requestId": "3b1c..."
}

type is urn:keel:problem:<kind> for every mapped error and about:blank otherwise. Extra fields carry the error's details (for example organizations on organization_required, limit on agent_limit_reached). Kinds you will meet:

Status Kinds
400 validation_failed, invalid_setup_state, setup_state_expired, invalid_return_url, missing_github_delivery_id, invalid_slug, invalid_name, invalid_api_key_expiry, invalid_callback_url, invalid_cron, invalid_timezone, schedule_required, schedule_not_allowed, missing_delivery_id, invalid_delivery_id, invalid_json, invalid_secret_name, invalid_env_var_name, reserved_env_var_name, duplicate_env_var_name, env_var_set_and_deleted, env_var_limit_exceeded, env_var_value_too_large, invalid_dotenv, invalid_email, invalid_repository_name, invalid_installation_id, invalid_branch, invalid_dockerfile_path, invalid_commit_sha, invalid_image_reference, image_outside_tenant_repository, invalid_manifest, organization_required, invalid_organization_selector, missing_email, malformed_body
401 unauthorized (bad or missing token), missing_signature, malformed_signature, timestamp_out_of_tolerance, signature_mismatch, no_active_secrets, invalid_signature
403 insufficient_permission, no_membership, no_organization, user_required, agent_limit_reached, spend_cap_exceeded, checkpoints_disabled, container_steps_disabled
404 installation_not_found, agent_not_found, run_not_found, trigger_not_found, callback_not_found, api_key_not_found, member_not_found, invitation_not_found, build_not_found, connection_not_found, repository_not_accessible, branch_not_found, image_not_found, not_found
409 organization_limit_reached, enterprise_is_invoiced, organization_is_invoiced, already_subscribed, checkout_in_progress, not_subscribed, installation_not_connected, slug_taken, agent_archived, agent_not_live, agent_has_no_revision, invalid_lifecycle_transition, callbacks_not_in_plan, tool_connectors_not_in_plan, feature_not_in_plan, services_not_in_plan, ingress_not_in_plan, workspace_too_large, service_limit_reached, service_start_gated, agent_not_a_service, service_not_running, mode_change_blocked, ingress_not_enabled, hostname_taken, run_already_terminal, run_not_resumable, no_checkpoint, run_changed, trigger_disabled, not_a_webhook_trigger, callback_in_flight, api_key_limit_reached, secrets_not_provisioned, env_secret_missing, already_member, last_owner, agent_not_bound, build_already_finished
410 payload_expired (a step payload the trace retention sweep deleted; the step metadata is still there)
404 payload_not_captured (the agent's traceCapture level meant the body was never recorded; the step metadata is still there)
403 trace_access_denied (the caller holds runs.read but not traces.reveal, or sits below the agent's traceAccess floor; the step metadata is still readable)
413 body_too_large, trigger_payload_too_large, checkpoint_too_large, step_payload_too_large
415 unsupported_media_type
429 rate_limited (with Retry-After in seconds), queue_depth_exceeded, too_many_streams
501 not_configured (a port such as the executor, the git host or the image builder is not configured in this environment; the problem names the capability and the requirement), provisioning_step_failed with cause: "not_configured"
500 audit_write_failed (a client-side audit event could not be stored)
503 not_ready

Endpoints

Permission membership means any member of the organization (or any API key of it). Public routes take no token.

Health and auth discovery

Endpoint Status Auth Permission
GET /health 200 public
GET /health/ready 200 (503 when a probe is down) public
GET /v1/auth/config 200 public Issuer, project id (audience) and the CLI, dashboard and MCP client ids.

Organizations, members, usage, signing secrets

Endpoint Status Permission Notes
GET /v1/organizations 200 signed-in user Tenant optional. Organizations the caller belongs to, oldest membership first.
POST /v1/organizations 201 signed-in user Tenant optional. { name, slug? }. Creates the row on the starter plan (plan changes only go through billing), the owner membership, then runs provisioning (the identity notes). Needs a verified email. A user may own at most MAX_ORGANIZATIONS_PER_USER organizations (default 3), beyond that 409 organization_limit_reached.
GET /v1/organizations/me 200 membership The resolved organization plus membership (users) or apiKey (keys).
GET /v1/organizations/me/usage 200 membership Current period from org_quota_state (runs and weighted minutes used and included, concurrency, spend cap) plus the finalized usage_records of the period and services: { hours, running, projectedCents, pricePerMonthCents, agent: { hours, running, projectedCents, pricePerMonthCents }, app: { ... } }: per service kind the hours settled so far this period (the sum of the service kind records of that kind), the number of services of that kind running right now, the projected spend for the period (hours times the hourly share of that kind's monthly price, rounded to whole cents) and the plan's flat price per replica-month (null on a plan without always-on services); the top level carries the totals and the agent price. Service hours never appear in runs or weightedMinutes.
GET /v1/organizations/me/plan 200 membership { plan: { id, name }, limits, features, pricing, verifiedEndpointListVersion }: the plan ceiling in force (PlanLimits, PlanFeatures and PlanPricing as in the catalog, including pricing.servicePriceCentsPerMonth and pricing.appServicePriceCentsPerMonth) and the current verified endpoint list version, enough to assemble a runtime manifest from scratch.
GET /v1/organizations/me/members 200 members.manage Members and pending invitations with status: active | pending. Owners and admins only; the directory (every member's email) is not exposed to viewers, plain members or API keys.
POST /v1/organizations/me/members 201 members.manage { email, role? }. Creates a pending membership on a placeholder user; it is claimed automatically the first time that email signs in with a verified email_verified claim. The InvitationMailer port is then asked to notify the address: INVITATION_MAILER=log (default) only logs, INVITATION_MAILER=ses sends a plain text email through SES v2 in eu-central-1 from INVITATION_MAILER_FROM, which must be an identity verified in that SES account. A mail failure is logged and does not fail the invitation. Granting owner needs org.manage.
GET /v1/organizations/me/invitations 200 members.manage Pending invitations only: { items: [{ userId, email, role, invitedAt }] }.
DELETE /v1/organizations/me/invitations/{userId} 204 members.manage Revokes a pending invitation; 404 invitation_not_found for an active member.
PATCH /v1/organizations/me/members/{userId} 200 members.manage { role }. The last owner cannot be demoted; touching an owner needs org.manage.
DELETE /v1/organizations/me/members/{userId} 204 members.manage Same owner rules.
GET /v1/organizations/me/signing-secrets 200 org.manage { items: [{ id, slot }] } where id is a fingerprint (first 16 hex of the SHA-256 of the secret) and slot is current or previous. Values are never listed. A read never creates a secret; an organization that never rotated answers an empty list (the first callback delivery creates the current secret on demand).
POST /v1/organizations/me/signing-secrets/rotate 200 org.manage { secret, id }. The secret is shown once; the previous current stays active (Outbound callbacks).

Billing

Self-serve billing for card organizations (Billing). Owners only: no API key scope grants billing.manage.

Endpoint Status Permission Notes
GET /v1/billing 200 billing.manage { billingEnabled, billingMode, plan, plans, subscription, profile, nextInvoiceAt, checkoutRequired, portalAvailable }. plans is the purchasable catalog with limits, features and pricing, each plan carrying annualPriceCents and monthlyPriceCents (null when the plan is annual only); subscription carries status, planId, interval, scheduledInterval, the term, renewsAt, cancelAtPeriodEnd and paymentProcessing; profile is the company name, address, tax id and tax exempt status collected in Checkout; nextInvoiceAt is the end of the current usage period while the subscription grants access; checkoutRequired is true when billing is enabled, the organization pays by card and it has no live subscription.
POST /v1/billing/checkout 201 billing.manage { planId, interval?, uiMode? } (starter or business; interval is monthly or annual, default annual). Creates the Stripe customer if needed and answers the embedded session's client secret or the hosted page url. 409 enterprise_is_invoiced, organization_is_invoiced, already_subscribed, checkout_in_progress, interval_not_available; 501 not_configured when BILLING=disabled.
POST /v1/billing/change-interval 200 billing.manage { interval }. Switches the plan subscription between monthly and annual and answers { interval, effective, effectiveAt, chargedNowCents }. Monthly to annual is effective: "now" with Stripe's proration; annual to monthly is effective: "period_end" with chargedNowCents: 0 and no refund. 409 same_interval, interval_not_available, interval_change_not_allowed (past due, unpaid, canceled, pending, invoiced or set to cancel), not_subscribed, subscription_pending, subscription_canceled; 501 not_configured when BILLING=disabled.
POST /v1/billing/portal 201 billing.manage { url } of a Customer Portal session (payment method, invoices, billing details, cancel at period end). 409 not_subscribed before a customer exists.
POST /v1/billing/stripe/webhook 200 public Stripe events, Stripe-Signature over the raw body. Answers { received: true, outcome } with outcome one of processed, duplicate, ignored, stale, unknown_subscription, unknown_customer, unknown_organization, unknown_plan; 401 invalid_signature or missing_signature.

API keys and audit log

Endpoint Status Permission Notes
POST /v1/api-keys 201 api_keys.manage { name, scopes, expiresInDays? } returns { key, plaintext }; the plaintext is never shown again. expiresInDays (1 to 365) stamps expiresAt; without it the key never expires. At most 50 active (not revoked, not expired) keys.
GET /v1/api-keys 200 api_keys.manage Prefix, scopes, lastUsedAt, revokedAt, expiresAt; never the secret.
DELETE /v1/api-keys/{id} 200 api_keys.manage Revokes and returns the key; idempotent.
GET /v1/audit-log 200 members.manage Cursor paged, newest first, ordered by (created_at desc, id desc) so ties are deterministic.
POST /v1/audit-log/events 201 membership { source: mcp | cli | dashboard, action, target, metadata? }. Records a client-side event (a read tool call, a CLI command, a dashboard view) as the acting principal; stored with action: "<source>:<action>" and metadata.source. The request itself is not audited a second time.

Every successful mutating request (POST, PUT, PATCH, DELETE) inside an organization writes one audit entry: actor (user id or apikey:<id>), action (METHOD /path/template), target (the created or addressed resource id) and metadata (principal, requestId, path, params). Failed requests are not recorded. The MCP server writes through the same AuditWriter, and records its read tools through POST /v1/audit-log/events.

Agents and revisions

Endpoint Status Permission Notes
GET /v1/agents 200 agents.read Paged. Each item is the agent plus lastRun: { id, status, endedAt } | null (newest run) and triggerKinds (distinct kinds of its enabled triggers), computed in the repository with two grouped queries per page, never per row. Every agent response carries mode: run | service and ingress: { hostname, alias, connected } | null (the public hostname when the current revision enables ingress).
POST /v1/agents 201 agents.write { name, slug?, defaultCallbackUrl?, mode?, serviceKind? }. A callback url needs the outboundCallbacks plan feature (409 callbacks_not_in_plan); mode: service needs the services feature (409 services_not_in_plan). serviceKind is agent (default) or app and sets the price of every replica of a service; every agent response carries it. PATCH /v1/agents/{id} accepts serviceKind too and answers 409 service_kind_locked (with the current serviceKind) while the service is up or a service run is active.
GET /v1/agents/{id} 200 agents.read The agent plus service: ServiceStatus | null (null for run mode; a service agent that was never started answers state: stopped).
PATCH /v1/agents/{id} 200 agents.write { name?, defaultCallbackUrl?, mode? } (null clears the url). Same plan gates. Changing mode while the agent has an active run or a running service is 409 mode_change_blocked with reason: active_run | service_running.
POST /v1/agents/{id}/pause, /resume, /archive 200 agents.write Lifecycle transitions; illegal ones are 409 invalid_lifecycle_transition.
POST /v1/agents/{id}/revisions 201 agents.write Body is a RuntimeManifest; limits are clamped to the plan. Non-empty toolGrants need the toolConnectors feature (409 tool_connectors_not_in_plan). An enabled workspace needs the services feature (409 services_not_in_plan) and sizeGb at most the plan's workspaceGbPerAgent (409 workspace_too_large with requestedGb and limitGb); an enabled ingress needs the ingress feature (409 ingress_not_in_plan).
GET /v1/agents/{id}/revisions 200 agents.read Paged.

Services and ingress

An agent in service mode is kept up by the platform: start creates a run with triggerSource: service, ceilingSeconds: 0 and deadlineAt: null (it runs until the service stops, so no deadline is ever computed for it), a crash ends that run and the platform starts the next one, and the hours a replica is up are billed per replica-month at the price of the agent's serviceKind (see services on the usage endpoint and Billing). A ServiceStatus is { agentId, state, currentRunId, restarts, lastExitCode, lastExitAt, nextRestartAt, upSince, updatedAt } with state one of stopped, starting, up, restarting, crash_looping, stopping.

Endpoint Status Permission Notes
GET /v1/services 200 agents.read { items }: every non-archived agent in service mode with its ServiceStatus, agentName, agentSlug and ingress (running ones first, then by slug). A service that was never started is listed as stopped.
GET /v1/agents/{id}/service 200 agents.read The ServiceStatus (which carries desiredReplicas, readyReplicas, generation and rollout: { revisionId, startedAt, failedAt } | null) plus currentRun (the run summary or null), uptimeSeconds (null while down), workspace: { bytes, limitGb } | null (bytes reported by the supervisor heartbeat against the revision's workspace size), ingress, replicas (one entry per replica index: index, state of starting, ready, unready, draining, held or stopped, ready, runId, revisionId, upSince, uptimeSeconds, restarts, lastExitCode, lastExitAt, nextRestartAt) and limits: { maxReplicas } from the plan. 409 agent_not_a_service for run-mode agents.
GET /v1/agents/{id}/metrics 200 agents.read Query range (1h, 24h, 7d, default 1h; picks 1 minute, 5 minute or hourly buckets), names (comma separated metric names, default all) and replica (all or a replica index, default all). Returns { agentId, runId: null, range, resolutionSeconds, from, to, timestamps, replicas, series, restarts }: timestamps is the aligned bucket timeline ending at the current bucket, replicas the indexes that reported in the range, every series is { name, unit, replica, points } with one { value, min, max, count } or null per timestamp, and restarts lists { at, replica, runId } for service runs that crashed in the range. Works for run mode agents too (no restarts). http_latency_p50_ms and http_latency_p95_ms are approximations: nearest rank percentiles of each 15 s relay flush window, merged into the bucket as a request weighted mean, exact only when the bucket had a single window. 400 validation_failed for an unknown range, name or replica. Metrics.
GET /v1/runs/{id}/metrics 200 runs.read The same document scoped to one run (runId set, restarts empty); 404 run_not_found.
POST /v1/agents/{id}/service/start 200 agents.write Starts the service on the current revision, resuming a paused agent; idempotent while it is running. 409 agent_not_a_service, agent_not_live (archived), agent_has_no_revision, services_not_in_plan, service_limit_reached with limit when the plan's maxServices other services are already running, or service_start_gated with gate (subscription_hold, spend_cap_hold, workspace_over_limit) when the reconciler refuses the start.
POST /v1/agents/{id}/service/stop 200 agents.write Pauses the agent, which ends the current run and keeps the workspace; idempotent when stopped.
POST /v1/agents/{id}/service/restart 200 agents.write Bumps the service generation; the reconciler then replaces every replica one at a time on the current revision (a new replica is started and becomes ready before the old one is drained and stopped), so a service behind a hostname keeps serving. 409 service_not_running when the service is stopped.
POST /v1/agents/{id}/scale 200 agents.write Body { replicas } (1 up to the plan's maxReplicasPerService). Stores the desired count on the agent (agents.desired_replicas, overriding the manifest's service.replicas without a new revision) and reconciles at once; returns the ServiceStatus with the new desiredReplicas. Each replica is billed as one service. 400 invalid_replicas, 409 agent_not_a_service, 409 replicas_exceed_plan with requested and max, 409 concurrency_exhausted with requested, available and concurrentRuns when the organization's free run slots cannot hold the replicas. Audited like every mutating call.
POST /v1/agents/{id}/ingress/rotate-alias 200 agents.write { ingressId, hostname, alias, connected, connectedAt } with a new alias; the previous alias stops resolving, the stable hostname (https://<slug>-<short org id>.svc.<domain>) is unchanged. 409 ingress_not_enabled when the current revision has no ingress.

The API keeps every service and ingress call behind two ports (apps/api/src/services/ports.ts: ServicesPort, IngressPort) that the composition root binds to the application's service and ingress use cases; an environment without them answers 501 not_configured for writes and empty statuses for reads.

Custom domains

A custom domain (Custom domains) is { id, hostname, target: { kind: "service", agentId } | { kind: "site", siteId }, status, platformHostname, dnsRecords, lastCheckedAt, lastError, verifiedAt, nextCheckAt, createdAt, updatedAt }. status is pending_verification, verifying, active or failed; dnsRecords lists { type, name, value, purpose } with purpose ownership (the TXT record), routing (the CNAME) or routing_apex (A/AAAA alternatives from INGRESS_PUBLIC_ADDRESSES). Hostnames are lower-cased and IDNA-encoded on input; wildcards, IP literals, single labels and anything under the platform domain are refused.

Endpoint Status Permission Notes
GET /v1/agents/{id}/domains 200 agents.read { items }: the domains pointed at the agent, oldest first.
POST /v1/agents/{id}/domains 201 agents.write Body { hostname }. 400 invalid_hostname (with reason) or platform_hostname; 404 target_not_found; 409 target_not_a_service, target_has_no_ingress (deploy a revision with ingress first), custom_domains_not_in_plan, custom_domain_limit_reached with limit, or hostname_taken when any organization already claimed the name.
GET /v1/sites/{id}/domains 200 sites.read { items }: the domains pointed at the static site, oldest first.
POST /v1/sites/{id}/domains 201 sites.write Body { hostname }; the target is { kind: "site", siteId } and platformHostname is the site's hostname. Same problems as the agent form except target_not_a_service and target_has_no_ingress; 404 target_not_found for an unknown or archived site.
GET /v1/domains 200 agents.read Every custom domain of the organization.
GET /v1/domains/{id} 200 agents.read One domain with its records. 404 custom_domain_not_found.
POST /v1/domains/{id}/verify 200 agents.write Checks the records now through public resolvers and returns the updated domain; idempotent, a failed check records lastError and the next automatic check time.
DELETE /v1/domains/{id} 204 agents.write Removes the domain; the relay stops serving it within the resolve cache TTL and the edge issues no further certificate for it.

Without INGRESS_PUBLIC_DOMAIN every domain endpoint answers 501 not_configured.

Static sites

Endpoint Status Permission Notes
GET /v1/sites 200 sites.read { items, usage: { siteBytes, sites }, limits: { maxSites, siteMaxBytes, siteMaxFiles } | null }, live sites only.
POST /v1/sites 201 sites.write { slug, name? }; the hostname is <slug>-<short org id>.svc.<domain>. 409 sites_not_in_plan, site_limit_reached (limit), site_slug_taken, hostname_taken (a service already owns the name); 501 not_configured without INGRESS_PUBLIC_DOMAIN.
GET /v1/sites/{id} 200 sites.read The site with currentDeployment and deployments (newest first, each flagged current and previous).
PATCH /v1/sites/{id} 200 sites.write { name?, settings?: { spaFallback?, notFoundPath?, headers?, redirects? } }. 400 invalid_site_settings with the domain error, 409 site_not_live.
DELETE /v1/sites/{id} 204 sites.write Archives the site; 409 site_not_live when already archived.
GET /v1/sites/{id}/deployments 200 sites.read { items }.
POST /v1/sites/{id}/deployments 201 sites.write Raw body: a gzip compressed tar of the site directory, Content-Type: application/gzip, at most SITE_MAX_UPLOAD_BYTES. Answers the ready deployment (fileCount, totalBytes, manifestHash). Failures are recorded on the deployment and answered as 400 invalid_file_path, malformed_archive, empty_deployment, 413 too_many_files, deployment_too_large, site_archive_too_large, 415 site_archive_unsupported.
POST /v1/sites/{id}/deployments/{deploymentId}/activate 200 sites.write Atomically makes the deployment live; the previous live one becomes the rollback target. 404 deployment_not_found, 409 deployment_not_ready, 409 site_changed (retry).
POST /v1/sites/{id}/rollback 200 sites.write Swaps current and previous. 409 nothing_to_roll_back, 409 previous_deployment_missing.

Serving, storage and the CLI are described in Static sites.

Runs

Endpoint Status Permission Notes
POST /v1/agents/{id}/runs 202 runs.trigger { payload?, callbackUrl? }. Returns the queued run immediately; poll or subscribe for the result. A per-run callbackUrl needs the outboundCallbacks feature. Admission is checked before anything is written: more than RUN_CREATE_RATE_LIMIT_PER_MINUTE creations per organization per minute (default 300) answer 429 rate_limited with Retry-After; a queue already holding the plan's queueDepthLimit runs answers 429 queue_depth_exceeded; an organization whose projected overage already exceeds its spend cap answers 403 spend_cap_exceeded instead of queueing a run the dispatcher would reject.
GET /v1/runs 200 runs.read Paged, newest first; filters agentId, status.
GET /v1/runs/{id} 200 runs.read Run plus latestAttempt, usage ({ billableSeconds, weightedMinutes, sizeWeight, peakMemoryMb } from the latest attempt's usage_records row, null until the attempt is metered), resumable (the run is failed or timed out, its revision enables checkpoints and a checkpoint exists) and latestCheckpointSequence.
GET /v1/runs/{id}/steps 200 runs.read Paged step trace. order=asc (default) walks forward, order=desc starts at the tail and the cursor walks backwards; afterSequence=<n> keeps only steps above n in either direction. Every step carries inputPayload and outputPayload, each available, expired (the body was deleted when the plan's trace retention window passed, the gateway notes) or absent (the step never had one); hasInput and hasOutput stay as booleans that are true only for available.
GET /v1/runs/{id}/steps/{sequence} 200 runs.read One step with payloads: { input, output }, each a presigned GET or null, plus the two presence fields. A step whose bodies expired keeps every other field and answers 200. 404 not_found for an unknown sequence.
GET /v1/runs/{id}/steps/{sequence}/payload?kind=input|output 200 traces.reveal Presigned GET for the payload. 403 trace_access_denied when the caller is below the agent's traceAccess floor, naming the floor. 410 payload_expired once the trace retention sweep deleted that body, with the plan window in the detail; 404 payload_not_captured when the agent's traceCapture level meant no body was ever written, naming the level; 404 not_found when the step never had one.
GET /v1/runs/{id}/logs?attempt=&afterChunk=&limit=&q=&from=&to=&levels=&replica=&stream=&cursor= 200 runs.read Container stdout and stderr of the latest attempt (or attempt), see below; with any of the search filters the page comes from the log search instead.
GET /v1/agents/{id}/logs?q=&from=&to=&levels=&replica=&stream=&cursor=&limit= 200 runs.read Search the last 7 days of container logs across every run and replica of the agent, newest first (Logs). { lines: [{ id, ts, stream, level, line, runId, attemptNumber, replicaIndex, chunkSequence, lineIndex }], nextCursor, retainedFrom }. levels is comma separated (error,warn,info,debug), limit at most 500. 400 log_query_too_broad (reason, minLength), 400 log_range_invalid, 400 log_cursor_invalid.
GET /v1/agents/{id}/logs/tail?q=&levels=&replica=&stream=&afterId= 200 runs.read Server-sent events of new log lines of the agent, see Logs.
GET /v1/logs/{lineId}/context?before=&after= 200 runs.read { line, before, after }: the neighbours of one log line within its run, 0..200 each side, 20 by default. 404 log_line_not_found once the line left the 7 day window.
GET /v1/runs/{id}/events 200 runs.read Server-sent events, see below.
POST /v1/runs/{id}/cancel 200 runs.trigger
POST /v1/runs/{id}/resume 202 runs.trigger Failed or timed out run with a checkpoint.
GET /v1/runs/{id}/callbacks 200 runs.read Delivery history: each callback with its status, deliveryId, nextAttemptAt and every attempt (responseStatus, responseBodyExcerpt, failureReason, durationMs).
POST /v1/callbacks/{id}/redeliver 202 callbacks.manage Queues the callback for immediate delivery; 409 callback_in_flight while an attempt is running.

Triggers and inbound webhooks

Endpoint Status Permission Notes
POST /v1/agents/{id}/triggers 201 agents.write { kind: manual | schedule | webhook, schedule?: { expression, timezone }, enabled? }. Returns { trigger, webhookSecret }; the secret is shown once. Schedules are synced to the scheduler on creation.
GET /v1/agents/{id}/triggers 200 agents.read
GET /v1/triggers/{id} 200 agents.read webhookUrl is the public endpoint for webhook triggers; scheduleSynced says whether the scheduler holds the schedule.
PATCH /v1/triggers/{id} 200 agents.write { enabled?, schedule? }. Disabling removes the schedule from the scheduler, enabling or rescheduling upserts it.
DELETE /v1/triggers/{id} 204 agents.write Releases the schedule and the webhook secrets.
POST /v1/triggers/{id}/rotate-secret 200 agents.write { trigger, secret }. The previous secret keeps verifying for the grace window.
POST /hooks/{triggerId} 202 public Raw JSON body, headers Keel-Signature: t=<unix seconds>,v2=<hmac hex> (HMAC-SHA256 of <t>.<Keel-Delivery-Id>.<raw body> with the trigger secret, five minute tolerance) and Keel-Delivery-Id (deduplication key, at most 255 characters). v1 (<t>.<raw body>) is still verified until 2027-03-31 but a v1 signature is also deduplicated on (trigger, t, signature), so a replay under a fresh delivery id answers duplicate: true. Answers { runId, deliveryId, duplicate }; a redelivered id answers duplicate: true with runId: null and never starts a second run. Disabled trigger: 409 trigger_disabled. More than HOOK_RATE_LIMIT_PER_MINUTE requests per trigger per minute (default 600) answer 429 rate_limited. See Webhook triggers.
POST /hooks/github 202 public GitHub App push events, X-Hub-Signature-256 over the raw body, X-GitHub-Delivery required and deduplicated (ignored: "duplicate_delivery"). Builds start only for connections made through the pushing installation. Answers { buildIds, ignored }.
GET /hooks/github/setup 200 or 302 public GitHub App setup redirect target: installation_id and the signed state from GET /v1/github/install-url. Records the installation for the organization in the state, then 302s to the state's returnTo with installation_id and account appended, or answers { installation, organizationId }. 400 invalid_setup_state or setup_state_expired, 403 insufficient_permission when the starting user lost builds.write, 404 installation_not_found.

Public marketing forms

Three endpoints exist for the marketing site (apps/web, keelhosting.dev) so that a lead lands on our own Frankfurt host rather than in a third-party database. The site posts only to POST /v1/public/sales-inquiries; the waitlist and enterprise routes stay for the rows already collected under them. They are unauthenticated and rate limited, and they are the only unauthenticated writes on the API besides the Stripe webhook and the ingest routes.

Endpoint Status Permission Notes
POST /v1/public/waitlist 202 public { email, source?, website? }. email is trimmed, lowercased and at most 254 characters; source is one of landing, hosting, trust, pricing (default landing); website is the honeypot the site sends empty. Answers { "accepted": true }. An address already on the list answers exactly the same body, so the endpoint never says whether an address is registered. A filled honeypot also answers { "accepted": true } and nothing is stored.
POST /v1/public/sales-inquiries 202 public { name, email, company, organizationSize, agentFootprint, callWindow, message?, website? }, trimmed, with caps of 200, 254, 200 and 5000 characters. organizationSize is one of 1-10, 11-50, 51-200, 201-1000, 1000+; agentFootprint one of none, 1-3, 4-10, 10+; callWindow one of morning, afternoon. message is optional and is stored as null when it is absent or blank. Answers { "accepted": true }. Every inquiry is kept, including repeats from one address.
POST /v1/public/enterprise-inquiries 202 public { name, email, company, message, website? }, trimmed, with caps of 200, 254, 200 and 5000 characters. Answers { "accepted": true }. Every inquiry is kept, including repeats from one address.

Both bodies are strict: an unknown field is 400 validation_failed, and no error ever echoes a submitted value. The route cap is 32 KiB (413 body_too_large) on top of the 1 MiB global limit. Rate limits are a token bucket on the client address (LEAD_RATE_LIMIT_PER_IP, default 5 per 10 minutes) and one on the email address (LEAD_RATE_LIMIT_PER_EMAIL, default 3 per hour), each answering 429 rate_limited with Retry-After in problem+json. The client address is request.ip, which fastify reads from the X-Forwarded-For header Caddy sets in front of the API, the same header the ingress relay forwards.

Rows go to the platform tables sales_inquiries, waitlist_signups and enterprise_inquiries, which carry no organization id and no row level security and are reachable only through the keel_platform role. No audit entry is written, because a public submission has no actor; each accepted submission writes one structured log line instead. the operations notes has the SQL to read them.

Builds and images

Endpoint Status Permission Notes
GET /v1/github/install-url 200 builds.write (signed-in member, not an API key) ?returnTo= (optional, must be a CORS_ALLOWED_ORIGINS origin, else 400 invalid_return_url). { url, expiresAt }: the App installation page with a signed ten minute state bound to the organization and the caller. 501 not_configured without the GitHub App.
GET /v1/github/installations 200 agents.read { items: [{ installationId, accountLogin, createdAt }] } recorded for the organization.
GET /v1/github/installations/{installationId}/repositories 200 builds.write { items: [{ fullName, defaultBranch, isPrivate }] } the installation can see, for picking one to connect. 409 installation_not_connected when the installation was not recorded for this organization.
POST /v1/repository-connections 201 builds.write { installationId, repositoryFullName }; the installation must have been recorded for this organization through the setup flow (409 installation_not_connected); idempotent per repository. 404 repository_not_accessible when the installation cannot see it.
GET /v1/repository-connections 200 agents.read
DELETE /v1/repository-connections/{id} 204 builds.write Also drops the agent bindings that used it.
GET /v1/agents/{id}/source 200 agents.read 409 agent_not_bound when the agent has no binding.
PUT /v1/agents/{id}/source 200 builds.write { connectionId, branch?, dockerfilePath?, autoDeploy? }. dockerfilePath omitted, empty or null means auto: the repository's Dockerfile, else one generated from the detected runtime (Builds without a Dockerfile). The response carries dockerfilePath: null in that case.
POST /v1/agents/{id}/builds 202 builds.write { commitSha?, deploy? }, default is the bound branch head. { build, created }; a queued, building or succeeded build for the same commit is returned with created: false. deploy is a deploy spec ({ environment, ingress: { port } or null, service: { replicas, healthCheck: { path } or null } or null, workspace: { sizeGb } or null }) applied on top of the agent's current manifest, or the plan defaults, when the build deploys.
POST /v1/agents/{id}/builds/upload?deploy= 202 builds.write Raw body: a gzip compressed tar of the project, Content-Type: application/gzip, at most BUILD_UPLOAD_MAX_BYTES (100 MB) compressed and four times that unpacked, 50,000 entries, no symlinks, no ... deploy is the JSON deploy spec above, URL encoded. Stores the archive under build-uploads/<organization>/<build>.tar.gz, records an upload build and starts it; the build always deploys on success (no source binding needed) and the archive is deleted once the build ends. 400 build_archive_malformed, build_archive_empty, build_archive_invalid_path, 413 build_archive_too_large, build_archive_too_many_files, 415 build_archive_unsupported, 503 build_archive_busy when BUILD_UPLOAD_MAX_CONCURRENT uploads are already being validated (retry). The deploy spec's environment is stored and returned in the revision manifest in plain text, so secrets belong in the secret store.
GET /v1/agents/{id}/builds 200 agents.read Paged, newest first.
GET /v1/builds/{id} 200 agents.read Read only: { build, revision } as stored. build.detectedRuntime (node 22, python 3.12 (fastapi), go 1.23, or null when the build used a Dockerfile or a pushed image) is what the builder reported. The build poller refreshes the status from the builder and deploys the revision when the binding has autoDeploy; a read never creates a revision.
GET /v1/builds/{id}/logs?nextToken= 200 agents.read { lines, nextToken }.
POST /v1/builds/{id}/cancel 200 builds.write
POST /v1/registry/push-credentials 200 builds.write { ttlSeconds? } (900 to 3600). Docker login scoped to the tenant repository.
POST /v1/agents/{id}/images 201 builds.write { imageReference, createRevision? } inside the tenant repository. Resolves the digest, records a push build and answers { build, revision, image: { repository, digest } }. With createRevision: false (the CLI's deploy flow) no revision is created and revision is null, so the caller can assemble its own manifest from the digest and the plan.

Builds that nobody polls are picked up by the in-process build poller (REAPER=inprocess) or, in production, by the worker.

Secrets

Endpoint Status Permission Notes
PUT /v1/secrets/{name} 204 secrets.write { value }. Names are [A-Za-z0-9_][A-Za-z0-9_.-]{0,127}. The value is never returned by any endpoint.
GET /v1/secrets 200 secrets.read_names { items: [{ name }] }.
DELETE /v1/secrets/{name} 204 secrets.write

Secrets live in the organization's tenant secret store. Locally SECRET_VAULT=file keeps them under SECRET_VAULT_ROOT and creates the store on first use. Without a store the answer is 409 secrets_not_provisioned. Names containing / are reserved for the platform (webhook secrets, per-agent environment secrets under agent/<agentId>/<NAME>) and never listed.

Per-agent environment variables

Endpoint Status Permission Notes
GET /v1/agents/{id}/env 200 agents.read { agentId, mode, vars: [{ name, kind: plain | secret, value, updatedAt, updatedBy }], hash, restartRequired, limits: { maxVars, maxPlainBytes }, history }. Secret values are null. history is the last 20 env.set, env.import and env.export audit entries as { id, action, actor, at, set, secretsSet, deleted, revealed }.
PUT /v1/agents/{id}/env 200 agents.write { vars: [{ name, value?, secret? }], deletes: [name] }, at most 100 entries each; upserts and deletes in one transaction, secret values stored in the tenant vault. Same response as GET. Audited as env.set with names only. 400 invalid_env_var_name, reserved_env_var_name, duplicate_env_var_name, env_var_set_and_deleted, env_var_limit_exceeded, env_var_value_too_large; 409 secrets_not_provisioned, agent_archived.
POST /v1/agents/{id}/env/import 200 agents.write { text, secret? } (dotenv text up to 256 KB). 400 invalid_dotenv with line and reason. Audited as env.import.
POST /v1/agents/{id}/env/export 200 secrets.reveal { text, names, secretNames }, dotenv text with the secret values. Owners and admins only; audited as env.export with the revealed names. 409 env_secret_missing.

Precedence, restart semantics and the dotenv grammar are in Environment and secrets.

Data stores

Endpoint Status Permission Notes
POST /v1/data-stores 201 data_stores.write { name, kind? }; kind is postgres (default) or valkey, name a slug of at most 32 characters. Provisions the store and writes its URL as the organization secret named in secretName (DATABASE_URL or REDIS_URL for main, <NAME>_DATABASE_URL or <NAME>_REDIS_URL otherwise). 409 data_stores_not_in_plan, data_store_limit_reached, data_store_name_taken, secret_name_taken; 502 data_store_provisioning_failed leaves a failed store to delete; 501 data_stores_not_configured when the deployment has no adapter for the kind.
GET /v1/data-stores 200 data_stores.read { items: [store] } with sizeBytes, sizeSampledAt, sizeLimitGb, pricePerMonthCents, deletedAt, purgeAt.
GET /v1/data-stores/{id} 200 data_stores.read One store.
DELETE /v1/data-stores/{id} 200 data_stores.write { id, status, purgeAt, purged }. Removes the secret now, keeps the data for seven days (purged: true only for a failed store, which is removed immediately). 409 data_store_busy while provisioning.
POST /v1/data-stores/{id}/rotate 200 data_stores.write New credentials, rewritten secret; running services keep the old URL until they restart. 409 data_store_not_ready.
POST /v1/data-stores/{id}/connection 200 data_stores.connect { id, name, kind, secretName, url }. Owners and admins only; audited like every mutating request with the store id as target, the URL is never stored. 409 data_store_not_ready or data_store_connection_missing.

See Databases. GET /v1/organizations/me/usage carries dataStores.postgres and dataStores.valkey with hours, active, projectedCents and pricePerMonthCents.

Ingest

/ingest/v1/attempts/{attemptId}/... routes are for the supervisor and authenticate with the run token; they are documented in the local development notes. /ingest/v1/blobs/{token} serves the local payload store and is not part of the public contract.

POST /ingest/v1/attempts/{attemptId}/heartbeat accepts an optional workspaceBytes (the bytes used under the workspace mount) which is recorded for the service that owns the run and shown as workspace.bytes on GET /v1/agents/{id}/service, and an optional ready: boolean (readiness on the heartbeat contract): the supervisor's verdict from the health check or the ingress port probe. It is stored per attempt (run_attempts.ready_at is set when the replica becomes ready and cleared when it stops being ready, last_ready_at remembers that it was ready once) and drives the rollout, the replica list and service_status.readyReplicas. A heartbeat without ready counts as ready while the agent container runs, so older supervisors keep working.

POST /ingest/v1/attempts/{attemptId}/logs takes { lines: [{ ts, stream: stdout | stderr, line }] }, at most 500 lines, 8 KB per line and 1 MB of text per batch, already redacted by the supervisor. Each batch becomes one gzip chunk runs/{org}/{run}/attempts/{n}/logs/{sequence}.ndjson.gz in the payload store plus a run_log_chunks row (organization, run, attempt, sequence, object key, bytes, line count, first and last timestamp). The same transaction also indexes every line of the batch into the log_lines hot table that backs search and the tail for seven days (Logs). The answer is { sequence, totalBytes, limitReached }; past 50 MB per attempt the platform answers limitReached: true and stores nothing more. Batches are only accepted while the run is running (409 run_not_active otherwise).

Run logs

GET /v1/runs/{id}/logs answers { attemptNumber, lines, lastChunk, nextAfterChunk, active }. Lines are { ts, stream, line } in the order the supervisor shipped them (per stream the order is the container's; across stdout and stderr it is the capture order, which is what docker logs gives too). The cursor is the chunk sequence: pass afterChunk=<lastChunk> (or nextAfterChunk) to continue, attempt= to read an earlier attempt (404 run_attempt_not_found for an unknown one), and limit= (default 2000, max 10000) for the number of lines per page; a page always carries whole chunks, at least one, and at most 5 MB of text decompressed server side. active is true while the run is still running its latest attempt, so a client that reaches nextAfterChunk: null with active: true should keep polling or switch to the event stream. A queued run without an attempt answers attemptNumber: null and no lines.

With any of q, from, to, levels, replica, stream or cursor the endpoint searches the run's lines in the 7 day hot table instead (Logs for the semantics): attempt still selects the attempt (404 for an unknown one) and the answer keeps the shape above with lastChunk: 0 and nextAfterChunk: null, lines newest first carrying id, level and replicaIndex, plus nextCursor (pass it back as cursor=) and retainedFrom; limit is capped at 500. Clients that never send a filter see no change.

Run events (SSE)

GET /v1/runs/{id}/events answers text/event-stream and polls the database every RUN_EVENTS_POLL_INTERVAL_MS (default 2000) until the run is terminal. This is what keel run --follow and the dashboard live view use.

event: status
id: status:running:1
data: {"event":"status","run":{...}}

event: step
id: 3
data: {"event":"step","step":{...}}

event: end
id: end:succeeded
data: {"event":"end","run":{...}}
  • status fires once per status change (including the first snapshot), step once per new step in sequence order, log with { attemptNumber, chunk, lines } for every batch of new container log chunks while the run is active (at most 2000 lines or 1 MB per event), end once with the terminal run, after which the server closes the stream.
  • When the run belongs to a service (triggerSource: service) the stream also carries service events with { event: "service", service: ServiceStatus } whenever the service state, restart count or current run changes (event id service:<state>:<updatedAt ms>). A crash ends the run (end with failed) after a service event with state: restarting; read GET /v1/agents/{id}/service for the next currentRunId and open its stream to keep following.
  • Step event ids are the step sequence, log event ids are log:<chunk>. Reconnect with Last-Event-ID: <sequence> or Last-Event-ID: log:<chunk> (or ?afterSequence= and ?afterLogChunk=) to resume without replaying; status is always resent. ?logs=false leaves the log events out.
  • Each poll reads only the steps above the last sequence it sent (the repository's afterSequence), so the cost of a poll is bounded by the new steps, not the trace length.
  • A : keepalive comment is written every RUN_EVENTS_KEEPALIVE_MS (default 15 s) so idle proxies do not drop the connection.
  • Every RUN_EVENTS_ACCESS_RECHECK_MS (default 60 s) the server checks that the caller still has runs.read in the organization (membership still present with a role that allows it, or the API key still active). If not, it sends event: access_revoked with {"event":"access_revoked"} and closes the stream; a revoked member or key stops receiving data within a minute instead of at reconnect.
  • At most RUN_EVENT_STREAMS_PER_ORGANIZATION (default 20) streams may be open per organization; the next one answers 429 too_many_streams with limit.
  • The client exposes it as for await (const event of client.runs.events({ params: { id } })).

Client

@keel/api-client (packages/api-client) is generated from the registry at build time (pnpm --filter @keel/api-client generate; the test suite fails when the committed output is stale).

import { createKeelClient, KeelApiError, paginate } from "@keel/api-client";

const client = createKeelClient({ baseUrl: "https://api.keel.dev", auth: { apiKey: process.env.KEEL_API_KEY }, organization: "acme" });

const agent = await client.agents.create({ body: { name: "Nightly digest" } });
const run = await client.agents.trigger({ params: { id: agent.id }, body: { payload: { date: "2026-09-16" } } });

const detail = await client.runs.get({ params: { id: run.id } });
console.log(detail.usage?.weightedMinutes ?? "not metered yet", detail.resumable);

const tail = await client.runs.listSteps({ params: { id: run.id }, query: { order: "desc", limit: 50 } });
const step = await client.runs.getStep({ params: { id: run.id, sequence: tail.items[0]?.sequence ?? 1 } });
console.log(step.inputPayload === "expired" ? "input payload expired" : (step.payloads.input?.url ?? "no input payload"));

const plan = await client.organizations.plan();
console.log(plan.limits.maxMemoryMb, plan.verifiedEndpointListVersion);

await client.auditLog.record({ body: { source: "cli", action: "runs inspect", target: run.id } });

for await (const event of client.runs.events({ params: { id: run.id } })) {
  console.log(event.event, event.data);
}

for await (const item of paginate(client.runs.list, { query: { agentId: agent.id, limit: 50 } })) {
  console.log(item.id, item.status);
}

try {
  await client.agents.get({ params: { id: "missing" } });
} catch (error) {
  if (error instanceof KeelApiError && error.is("agent_not_found")) {
    console.log(error.status, error.requestId);
  }
}

Every method takes { params, query, body } as the registry declares and an optional { signal, headers, organization }; auth is { bearer } for Zitadel tokens or { apiKey }; fetch is injectable for tests; responses are validated against the contract unless validateResponses: false. JSON requests abort after timeoutMs (default 30 seconds, defaultRequestTimeoutMs) with a TimeoutError; a caller's signal is combined with the timeout so either ends the request. Event streams have no default timeout, only the caller's signal.