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-Organizationalso 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_failedwitherrors: [{ 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":{...}}
statusfires once per status change (including the first snapshot),steponce per new step in sequence order,logwith{ 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),endonce with the terminal run, after which the server closes the stream.- When the run belongs to a service (
triggerSource: service) the stream also carriesserviceevents with{ event: "service", service: ServiceStatus }whenever the service state, restart count or current run changes (event idservice:<state>:<updatedAt ms>). A crash ends the run (endwithfailed) after aserviceevent withstate: restarting; readGET /v1/agents/{id}/servicefor the nextcurrentRunIdand open its stream to keep following. - Step event ids are the step sequence, log event ids are
log:<chunk>. Reconnect withLast-Event-ID: <sequence>orLast-Event-ID: log:<chunk>(or?afterSequence=and?afterLogChunk=) to resume without replaying; status is always resent.?logs=falseleaves 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
: keepalivecomment is written everyRUN_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 hasruns.readin the organization (membership still present with a role that allows it, or the API key still active). If not, it sendsevent: access_revokedwith{"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 answers429 too_many_streamswithlimit. - 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.