Docs
Services and APIs
Always-on containers: replicas, rollouts, health checks, restarts and what a crash loop does.
An agent in service mode is an always-on daemon: a Telegram or Slack bot, a long-lived gateway, an API, anything that must stay up. Keel keeps one run of it alive per replica, restarts a replica when it crashes, keeps its working directory on a persistent volume, and bills every replica as a flat replica-month pro-rated by the clock hour, at one price whatever it runs: Agent.serviceKind is agent (an AI agent or bot, the default) or app (a regular backend such as an API, a web app or a worker), and the kind now describes the workload rather than setting the price. Runs of service agents are ordinary runs with a service trigger source, no deadline, and a reconciler instead of a trigger. A service with ingress can run several replicas behind its hostname, a new revision is rolled out one replica at a time without dropping a request, and a replica that fails its health check is replaced while the others keep serving.
Model
Logs of every replica flow through the same ingest path as run-mode logs and are searchable across the replicas of a service for seven days, with the replica index on every line:
GET /v1/agents/{id}/logs,keel logs <slug> --replica 1and the agent logs page in the dashboard (Logs).agents.modeisrunorservice. A service agent's runs are created only by the reconciler (triggerSource = service); a manual, scheduled or webhook trigger on a service agent is refused withagent_mode_mismatch, and aservicetrigger on a run mode agent likewise.agents.service_kind(migration0028,agentorapp, defaultagent; domainAgent.serviceKind,serviceKinds) only means something in service mode and decides the price of every replica (Billing). It is set onPOST /v1/agents(serviceKind) fromservice.kindinkeel.yamland changed withPATCH /v1/agents/{id};UpdateAgentrefuses the change withservice_kind_lockedwhile the service is up or any service run of the agent is active (serviceIsActive), so a billing hour never straddles two prices. Every PATCH is in the audit log like any other mutating request.A service run has
ceilingSeconds = 0anddeadlineAt = null. The supervisor runs without a deadline timer, the reaper never times it out for its deadline or its queue wait, and the gateway binding for it expires a year out instead of at the deadline (serviceBindingTtlSeconds). Dispatch, provisioning and lost handle detection still apply, from the attempt's dispatch time (run_attempts.created_at).service_status(migration0017, one row per agent, RLS like every tenant table) carries the reconciler's view:state,current_run_id,restarts,last_exit_code,last_exit_at,next_restart_at,up_since, and since migration0026desired_replicas,ready_replicas,generationand the rollout columns (rollout_revision_id,rollout_started_at,rollout_failed_at). The domain type isServiceStatus(service_status.readyReplicasandservice_status.desiredReplicasare the public seams other workstreams read), the portServiceStatusRepositoryonTenantRepositories.serviceStatus.service_replicas(migration0026, one row per(agent_id, replica_index), RLS like every tenant table) carries the per replica restart accounting:run_id(the run occupying the index),claimed_at(a start in flight),restarts,last_exit_code,last_exit_at,next_restart_at,up_since. Domain typeServiceReplica, portServiceReplicaRepositoryonTenantRepositories.serviceReplicas. Service runs carryruns.replica_indexandruns.service_generation; readiness lives per attempt onrun_attempts.ready_atandlast_ready_at.Desired state (
serviceDesiredState): a live service agent wants to be up; paused or archived wants down. The desired replica count isagents.desired_replicaswhen set (POST /v1/agents/{id}/scale), elsemanifest.service.replicas, else 1 (desiredReplicasFor).
States and the transitions the reconciler and the ingest path take:
| State | Meaning |
|---|---|
stopped |
Nothing should run, or the platform stopped it (pause, spend cap, subscription gate, platform loss). |
starting |
The reconciler claimed a start and created (or is creating) the run. |
up |
The attempt registered with the ingest API. upSince is set by the first heartbeat. |
restarting |
A replica ended and its replacement is due, or a rollout is in progress and no replica of the current revision is running yet. |
crash_looping |
Five consecutive crashes; held until nextRestartAt. |
stopping |
Reserved for an explicit stop in flight. |
Replicas and health checks
The manifest's service block (packages/domain/src/agents/manifest.ts, validated by runtimeManifestSchema, written from keel.yaml) declares:
service:
replicas: 2
healthCheck:
path: /healthz
port: 8080
intervalSeconds: 10
timeoutSeconds: 5
unhealthyThreshold: 3
startPeriodSeconds: 30
replicas defaults to 1 and is clamped by clampManifestToPlan to plan.limits.maxReplicasPerService (Free 1, Pro 3, Enterprise 10). healthCheck is optional; port defaults to the ingress port (and is required when the service has no ingress, the manifest schema refuses it otherwise), path is a plain request target (printable ASCII, no spaces), and the other fields default to the values shown. Each replica is billed as one service (Billing).
Readiness is decided by the supervisor and reported on the ingest heartbeat as ready: boolean (heartbeatReadyField in @keel/runtime-contract; readiness on the heartbeat contract) and, for ingress services, on the tunnel's control channel as stats.ready:
- With a
healthCheckthe supervisor sendsGET <path>to the agent container over the shared network (KEEL_HEALTH_CHECK, JSON with the port filled in by the executor) everyintervalSecondswithtimeoutSeconds; any 2xx or 3xx is a pass. The replica is ready after the first pass; with ingress the pass only counts once the tunnel is connected, so the reconciler never drains the old replica before the relay can route to the new one. Misses during thestartPeriodSecondsafter the run started do not count; afterwardsunhealthyThresholdconsecutive misses make it not ready, and one pass makes it ready again. - With ingress but no
healthCheck, readiness is "the tunnel is connected and the ingress port accepts a TCP connect", probed with the default interval, timeout and threshold. - Without either, a replica is ready as soon as its agent container runs.
RecordHeartbeat stores the verdict per attempt: 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. From those the reconciler sees each active run as launching (never ready), ready, unready (was ready, now failing) or draining, and service_status.readyReplicas counts the ready replicas of the current revision and generation.
Rollouts, scaling and replica restarts
The rollout state machine is a pure domain function, planReplicas in packages/domain/src/services/rollout.ts. Given the desired count, the active runs (index, current or stale, phase, since when, drained) and the per replica holds it returns orders, which the reconciler applies:
- A stale run is one whose revision is not the agent's current one or whose
service_generationis not the status generation (a restart bumps the generation, see below). - Rolling deploy: while stale runs exist the planner starts one replica of the current revision at a time, waits until it is ready (bounded by
startPeriodSeconds + 60 s,readinessDeadlineMs), then drains the stale run at the same index, stops it once drained (service_superseded) and moves to the next index. A ready replica of the old revision keeps serving throughout; the surge is one run slot. Without a free concurrency slot a deploy never stops the last ready replica: the reconciler reportsconcurrency_exhaustedevery tick, the old revision keeps serving withrolloutset and staterestarting, and the rollout proceeds as soon as a slot frees (planReplicasinputreplaceWithoutSurgeis false). A stale run that is not the last ready one (another ready replica exists, or it never became ready) is drained and stopped first (service_restart) so its slot goes to the new replica. Only an explicit restart (POST /v1/agents/{id}/service/restart, every stale run is on the current revision, so the user asked for the replacement) may drain and stop the last ready replica without a slot for the surge. - A new replica that is running but misses its readiness deadline while an old one still serves aborts the rollout: the new run is stopped (
canceledwithhealth_check_failed, stop reasonrollout_aborted), the old replicas keep serving,service_status.rollout.failedAtis set andServiceNotifier.serviceRolloutAbortedfires once. The reconciler does not retry that revision (rollout_failed_hold) until a new revision is deployed, the service is restarted or the old replicas are gone. - Scale up starts every missing index that a free concurrency slot allows (
concurrency_exhaustednames the shortfall and retries on the next tick); scale down drains the indexes above the desired count and stops them once drained (service_scaled_down), never touching the ones that stay.POST /v1/agents/{id}/scalerefuses a count beyond the plan or beyond the organization's freeconcurrentRunsbefore anything is stored. - An unhealthy replica (
unready) is stopped withhealth_check_failed, which the restart policy treats as a crash of that replica alone: itsservice_replicasrow gets the backoff and the count, the other replicas are not touched. A replica that never became ready within the deadline and has nothing to fall back to is treated the same way. - Draining (the ingress notes): the reconciler marks the run's tunnels draining in
ingress_tunnels(ServiceTunnelDrainer, implemented byIngressTunnelDrainerover the ingress binding tables); the relay stops routing to them and recordsdrained_atonce nothing is in flight; the reconciler stops the run when every tunnel drained or afterdrainTimeoutMs(30 s). A run without tunnels is stopped at once. - Restart (
POST /v1/agents/{id}/service/restart,RollingRestartService) bumpsservice_status.generation; every run of the old generation is stale, so the same rolling machinery replaces the replicas one by one on the same revision.
service_status.currentRunId points at the lowest index replica of the current revision, and the run event stream of that run carries the service events as before.
Reconciler
ReconcileServices runs as the service-reconciler worker role every 15 seconds, and ChangeAgentLifecycle and CreateRevision invoke it for the changed agent through the optional onAgentChanged listener (AgentChangeListener), so a pause, resume, archive or deploy takes effect without waiting for the tick. It reads the platform-wide ServiceDirectory (service agents and active service runs, one query each) and then works per agent inside a tenant transaction that locks the status row.
For every service agent:
- Desired down: every active run is stopped through
stopActiveRun(acanceledoutcome with stop reasonservice_stop), the status becomesstopped, and for an archived agent the workspace is removed and the status row deleted so the agent leaves the directory. - Desired up: the reconciler builds the replica view (
buildReplicaSetinpackages/application/src/services/replica-view.ts: the active runs with their attempts' readiness, the drain state of their tunnels, the replica rows and their holds), asksplanReplicasfor orders and applies them: stops throughstopActiveRun(canceled for superseded and scaled down runs,failedwithhealth_check_failedfor unhealthy ones), drains through the tunnel drainer, and starts throughCreateRunwith the replica index and generation on the run. Starts pass the start gates first, in order: workspace over its allowance,maxServices(active services of other agents in the organization; 1 on Free, unlimited on Pro and Enterprise), the subscription gate (BillingAccessPolicy), and the spend cap projection with a one hour service reservation (decideAdmissionwithserviceReservation: a slot plus one hour at the hourly service price, no run minutes). A closed gate leaves the status untouched and is reported as the gate's name. Otherwise each started index is claimed on itsservice_replicasrow (claimed_at), the workspace is ensured, the run is created and recorded on the row; a failed create releases the claims with a five minute hold, and a claim older than two minutes with no run is treated as stale. A running replica of the current revision whose row has no owner (the reconciler died between creating the run and recording it, or a duplicate at the same index was drained) is adopted by its row on the next tick (adoptUnownedRuns) so its crashes and stable uptime keep being accounted. Replicas whosenext_restart_atis in the future are held (held); a service level hold (service_status.next_restart_at, crash loop or gated stop) holds every start but drains and stops still complete. The status is then derived from the runs:upwhen a current replica is running,startingwhile the first ones launch,restartingwhile a rollout runs with nothing new up yet or a crashed replica waits for its backoff, plusdesiredReplicas,readyReplicasandrollout. - Cases reported per agent (
ReconcileAction.case):start,rolling_restart_start,restart_stop,superseded_stop,draining,scale_down,waiting_readiness,rollout_aborted,rollout_failed_hold,unhealthy_restart,concurrency_exhausted,held,crash_loop_hold,marked_up,noop, the gate names,stop,workspace_removedandnot_a_service.
Restart accounting lives in the domain (packages/domain/src/services/restart-policy.ts) and is applied by finalizeAttempt (recordServiceExit) when a service run reaches a terminal state. It is kept per replica; the hold is per service:
- A crash is any agent exit, including exit 0 (a service is not supposed to exit) and a
health_check_failedstop: the replica row that owns the run getsrestarts + 1,lastExitCode,lastExitAtandnextRestartAtwith exponential backoff5 s * 2^(restarts - 1)capped at 5 minutes (recordReplicaCrash). The count resets once that replica stayed up for ten minutes (upSince). The service row mirrors the sum of the replica counts, the last exit, and goes torestartingonly when no other replica is running (recordServiceCrash); while others run it staysupand only the crashed index waits. - A replica's fifth consecutive crash enters the whole service into
crash_loopingwith a fifteen minute hold onservice_status.next_restart_at; the running replicas keep running, no replica is started, the reconciler notifies once (ServiceNotifier.serviceCrashLooping) and the looping replica starts fresh withrestarts = 0after the hold. - Platform stops are not crashes:
platform_lost,dispatch_lost,provisioning_timeout,image_pull_failed,platform_error, a user or reconciler cancel (superseded, scaled down, paused). The replica row frees its run with no hold, so a lost replica restarts on the next tick. A stop forspend_cap_exceededorsubscription_inactiveis also not a crash but carries a five minute hold on the replica and the service so the gate is not hammered. - A terminal report for a run that no replica row owns (the superseded half of a rollout, or an aborted rollout's new run whose index was handed back to the old run) does not touch the accounting.
Workspaces
manifest.workspace = { enabled, mountPath, sizeGb } mounts a per-agent volume at mountPath in the agent container and, at the same path, in the supervisor. clampManifestToPlan refuses a size above plan.limits.workspaceGbPerAgent (workspace_exceeds_plan), refuses ingress without the plan feature, and refuses service mode without plan.features.services; these are typed errors on CreateRevision, nothing is silently cleared.
- Docker: a named volume
keel-ws-{agentId}(DockerWorkspaceStore), created before the first start and bound into both containers. It survives runs and restarts; archiving the agent removes the agent's finished containers and the volume. - Fargate: one EFS file system per organization, created by the
workspaceprovisioning step (EfsTenantWorkspaceProvisioner: encrypted with the tenant key, elastic throughput, one mount target per run subnet in the workspaces security group, a file system policy that admits access point mounts over TLS and denies plaintext), recorded asorganizations.workspace_file_system_id. One access point per agent (EfsWorkspaceStore: uid and gid 1000, root/agents/{agentId}created 0750), recorded asagents.workspace_access_point_id. The task definition declares akeel-workspacevolume withefsVolumeConfiguration(transit encryption on, IAM authorization off because Keel tasks have no task role) mounted read-write in the agent and the supervisor. Archiving deletes the access point; the directory stays on the file system until a retention job removes it. - Size: EFS cannot cap a directory, so the supervisor reports
workspaceBytes(a bounded walk of the mount path, sampled once a minute) on every heartbeat andRecordHeartbeatstores it onagents.workspace_bytes. The settlement job compares it with the manifest allowance, and on Fargate the file system's metered size with the sum of the organization's allowances, notifies once per process (ServiceNotifier.workspaceOverLimit), and the reconciler refuses new starts of an over-limit agent (workspace_over_limit) while a running one is left alone.
Terraform (infra/terraform/modules/workspaces) provides the security group, the API role rights, and the environment variable WORKSPACE_SECURITY_GROUP_ID; with it set and RUN_SUBNET_IDS present, createAwsProvisioners returns a workspaceProvisioner and provisioning runs the extra step.
Supervisor
KEEL_DEADLINE_ATis optional; the registration response'sdeadlineAtmay be null. Without a deadline the deadline phase never fires.KEEL_WORKSPACE_PATHmakes the heartbeat carryworkspaceBytes(the field name isheartbeatWorkspaceBytesFieldin@keel/runtime-contract).- Every 10 s the supervisor samples the agent container's CPU, memory and network (from the executor's
AGENT_STATSfile on Docker, from the task metadata stats on Fargate;KEEL_CPU_UNITSis the allocation CPU is scaled by) and batches up to six samples on the next heartbeat undermetrics(heartbeatMetricsField).RecordMetricsstores them as minute points on the run's replica; Metrics. KEEL_HEALTH_CHECK(JSON{ path, port, intervalSeconds, timeoutSeconds, unhealthyThreshold, startPeriodSeconds }, set by both executors from the manifest for service launches, the port already resolved against the ingress port) starts the readiness prober (apps/supervisor/src/readiness); without it an ingress service probes its port over TCP. Every heartbeat carriesready, a change of readiness sends a heartbeat at once, and the tunnel's periodicstatscarryreadytoo, so the relay learns about it without waiting for the platform.- On SIGTERM the supervisor first drains its tunnel (announces
ready: falseon the control channel and waits up to ten seconds for its in-flight requests to finish), then writes/keel/control/STOPand waits up toKEEL_STOP_GRACE_SECONDS(default 30) for the agent to exit before it reportsstopped. An agent that wants a clean shutdown polls that file. The Docker executor stops the supervisor first for service launches (labelkeel.service=true) and gives it the grace plus its own stop timeout; on Fargate every container receives SIGTERM at once and the agent'sstopTimeoutof 30 seconds is the grace.
Billing
See Billing, section "Services are billed per replica-month". In short: a service costs pricing.servicePriceCentsPerMonth (79 EUR) per replica, pro-rated by the hour it is up, and the steps its runs record count against the per-agent step allowance. Dispatch reserves one concurrency slot (serviceReservationFor(agent.serviceKind)), the service-settlement role writes one service kind usage record per completed clock hour (usage_records.window_start, usage_records.service_kind) and adds its hours to org_quota_state.period_service_hours or period_app_service_hours while the slot stays taken, terminal writes the last partial window and frees the slot, the rollup reports the hours on the service_hours or app_service_hours meter, and when the settled hours push the spend projection over the cap every running service of the organization is stopped with spend_cap_exceeded and notified.
Metrics
Each replica's CPU, memory and network and every request through its hostname are recorded as metric points and served on GET /v1/agents/{id}/metrics, in the dashboard's Metrics section and by keel service metrics. Restart markers come from the service runs that crashed. Metrics has the measurements, resolutions, retention and write volume.
Data stores
A service reaches a managed Postgres or Valkey store through an organization secret: keel db create main writes DATABASE_URL (or REDIS_URL), the service lists that name under secrets: in keel.yaml, and the next deploy or keel service restart injects it as an environment variable. The database is only reachable from the agent network at db.internal, so nothing changes in the egress policy. Rotating credentials rewrites the secret but does not restart the service. Details in Databases.
Environment variables
Beyond the manifest's environment and secrets, every agent carries its own editable set of variables (Environment and secrets): service_env_vars rows, secrets in the tenant vault under agent/<agentId>/<NAME>, edited through PUT /v1/agents/{id}/env, keel env and the agent page. DispatchRun reads the set inside the admission transaction, stores its hash on runs.env_hash and hands it to the executor as LaunchInput.serviceEnvironment; both executors merge it with mergeRuntimeEnvironment (manifest environment, then per-agent plain values, then manifest secrets, then per-agent secrets). A change never touches a running replica: GET /v1/agents/{id}/env reports restartRequired when an active service run was dispatched with a different hash, and POST /v1/agents/{id}/service/restart rolls the replicas onto the current set. Archiving the agent purges the rows and the vault entries.
Operations
- Worker roles:
service-reconciler(15 s),service-settlement(5 min) andmetrics-rollup(5 min, rolls minute metric points up and expires them; Metrics). Both compose the same executor as the dispatcher and reaper; the reconciler also needs aCreateRunand the dispatch queue.domain-verifier(1 min) rechecks the DNS of custom domains (Custom domains) and needsINGRESS_PUBLIC_DOMAINbut no executor.data-store-settlement(5 min) anddata-store-purge(1 h) follow the same shape for managed data stores (Databases). - Environment:
WORKSPACE_SECURITY_GROUP_ID(API and worker, Fargate only). Everything else reuses the run task settings. - Stop reasons on
run_attempts.stop_reason:service_stop(pause or archive),service_superseded(replaced by a ready replica of the new revision or generation),service_restart(replaced without overlap because no slot was free and either another ready replica kept serving or the user asked for a restart),service_scaled_down,health_check_failed(unhealthy replica),rollout_aborted(new replica never became ready),spend_cap_exceeded. - Rollout and readiness limits: readiness deadline
startPeriodSeconds + 60 s, drain timeout 30 s (drainTimeoutMs), one surge slot per rollout. The relay's drain poll interval isINGRESS_DRAIN_POLL_MS(default 1000).
How the API binds to this layer
apps/api/src/composition/service-ports.ts builds one ReconcileServices and one SettleServiceUsage per process from the run ports (executor, terminal listeners, billing access, dispatch queue) and hands them out three ways: as the ServicesPort behind /v1/agents/{id}/service/*, as the onAgentChanged listener of ChangeAgentLifecycle and CreateRevision, and as the in-process loops that REAPER=inprocess starts next to the reaper (SERVICE_RECONCILE_INTERVAL_SECONDS, SERVICE_SETTLEMENT_INTERVAL_SECONDS). The worker composes the same use cases for the service-reconciler and service-settlement roles.
- Status:
TenantRepositories.serviceStatus.find(agentId)returns theServiceStatus;currentRunIdpoints at the run whoseerrorClassand latest attempt'sstopReasonexplain a stop. - Replicas:
ServicesPort.replicas(replicaViewsForinapps/api/src/composition/replica-views.ts) joins the active runs, their attempts and theservice_replicasrows into one entry per index withstate,ready,runId,revisionId,upSince,restartsand the hold.ServicesPort.scalerunsScaleService(plan and concurrency checks, thenagents.desired_replicasandonAgentChangedwithchange: "scale");restartrunsRollingRestartService(change: "restart"). - Start: a paused agent is resumed (
ChangeAgentLifecyclewithresume), which reconciles throughonAgentChanged; a live one goes straight toReconcileServices.reconcileAgent. A closed start gate comes back asservice_start_gatedwith the gate name, exceptmax_services_reached, which the API reports asservice_limit_reachedwith the plan limit. The reconciler is the only placemaxServicesis counted. - Stop: pause the agent (
ChangeAgentLifecyclewithpause), same hook; the desired state of a paused agent is down, so the reconciler stops the run withservice_stop. - Restart:
RollingRestartServicebumps the generation and reconciles; the old replicas are drained one at a time as the new ones become ready. - Workspace usage:
agents.workspaceBytes(last heartbeat) againstmanifest.workspace.sizeGb; on FargateEfsWorkspaceStore.meteredBytes(organizationId)for the organization total. The heartbeat'sworkspaceBytesgoes toRecordHeartbeat. - Services on
GET /v1/organizations/me/usage:services.agentandservices.appeach carryhours(the sum ofbillable_secondsover the period'sservicekind usage records of that kind, in hours),running,projectedCents(the hours at the hourly share of that kind'spricePerMonthCents) andpricePerMonthCents;services.hours,runningandprojectedCentsare the totals andservices.pricePerMonthCentsthe agent price.GET /v1/agents/{id}/servicecarriespricing: { serviceKind, pricePerReplicaMonthCents }andGET /v1/servicesitems carryserviceKind. - Archive:
ChangeAgentLifecyclerevokes the ingress binding, and the reconciler revokes it again, removes the workspace and deletes the status row when it sees the archived agent; both paths are idempotent. On Docker the workspace store stops any container that still holds the volume before removing it, so the archive request and the reconciler loop do not fight over the volume.