Build a local agent worker
Build a local worker when your runtime needs to execute One Horizon agent sessions on a user's machine. The loop is resource-based: register a worker, send heartbeats, poll sessions, claim one session, run it, then complete, fail, or release the claim.
If you only want to run the built-in Codex worker, use the local worker quickstart instead. The Desktop app can create and start that worker for you, and the CLI gives you the same worker loop from a terminal.
Core model
Four records make up the local worker loop:
| Record | What it means for your worker |
|---|---|
| Agent | The app capability users can send work to. |
| Worker | Your local runtime instance for that agent and workspace. |
| Session | One queued unit of work, often tied to a task or initiative. |
| Claim | The active lease that lets your worker execute one session. |
Session creation queues work. A successful claim authorizes execution.
Authentication
Local workers use OAuth user tokens. The authenticated user must own the worker and the local sessions it can see.
Do not use workspace API keys for local worker execution. API keys cannot register local workers, claim sessions, emit activities, or call worker-scoped session endpoints.
Desktop-managed workers use the signed-in Desktop session to perform setup and start the local process. That does not change the server contract: the worker still belongs to one user, one workspace, and one agent.
A local worker can run code on a user's machine. Keep local execution owner-only, and treat workspace-authored content as untrusted input.
Register the worker
Find or create a local worker for the workspace, agent, and authenticated user.
Store the returned agentId and workerId in your local config so later runs can reuse the same worker. If the server rejects the saved worker, register again instead of silently falling back to task search or direct execution.
For a graphical setup flow, mirror Desktop: ask for the repo folder, workspace mode, workflow file, worker name, and optional worker instructions. Save the returned worker ID before starting the process.
The worker record should describe:
- Workspace and agent
- Execution mode
local - Owner user
- Worker name or display label
- Runtime support and policy limits
- Optional worker-level custom instructions
- Status and heartbeat timestamps
Send heartbeats
Send a heartbeat every 30 seconds while the worker runs. One Horizon marks a worker stale after 2 minutes without a heartbeat and offline after 10 minutes. When a worker goes offline, the platform expires active claims and returns those sessions to the claimable pool.
Heartbeat requests accept coarse runtime facts such as OS platform and runtime version. Do not include machine names, usernames, private paths, IP addresses, or repository names.
If the heartbeat endpoint returns 404, the worker record was deleted. Clear the saved worker ID and stop polling. Do not re-register without explicit user confirmation.
Do not emit an activity for every heartbeat. Activities should describe progress, policy decisions, plan changes, external URLs, completion, failure, or input needs.
Respond to control signals
Poll your worker record every 5 seconds while the worker is running. The worker record may carry a pending control signal set by a workspace admin or automation.
| Signal | Required behavior |
|---|---|
stop | Finish the current session turn, then exit cleanly. |
pause | Stop dispatching new sessions. Keep any active run alive until it finishes. |
resume | Resume dispatching sessions after a pause. |
restart | Stop the worker cleanly and start a fresh worker process when the launcher supports respawn. If respawn is not supported, exit after acknowledging the signal. |
After acting on a signal, acknowledge it by posting to the ack-control-signal endpoint. Unacknowledged signals remain pending and will be returned on every subsequent poll.
Poll sessions
Poll sessions through the worker-scoped session endpoint for your agent and worker. The server returns only sessions this worker is allowed to claim.
For local workers, eligible sessions must belong to the authenticated owner. If the user changes accounts or workspaces locally, stop and re-register under the new context.
Claim before execution
Claim the session before treating it as executable work. The claim is the concurrency and authorization boundary.
Pass leaseSeconds in the claim request to set the lease duration. The default is 900 seconds (15 minutes). If the lease expires before your worker completes, fails, or releases the session, the platform marks the session stale and returns it to the claimable pool. Another eligible worker can claim it without any manual intervention.
When the claim succeeds, store the returned claimId with local run state. Include it in every activity, patch, completion, failure, and release call for that session. Calls without the active claimId are rejected.
If the claim returns a conflict, skip that session. Another worker claimed it first, the session was cancelled, or the worker is no longer eligible.
Keep trust boundaries clear
Build the runtime prompt or job payload from separate inputs:
- Trusted instructions: One Horizon system policy, agent profile config, worker config, and trusted local workflow policy. Agent and worker custom instructions are trusted configuration and are limited to 2,000 characters each.
- Untrusted context: task titles, descriptions, comments, documents, user prompts, and other workspace-authored content.
Pass trusted instructions separately from untrusted context when you call your model or runtime. Untrusted context can guide the work, but it must not change sandbox policy, credentials, allowed commands, network access, or external side effects.
Enforce worker policy
The model can request an action. The worker decides whether the action is allowed.
Keep policy checks explicit for:
- Filesystem access
- Shell execution
- Network access
- One Horizon workspace writes
- External side effects such as commits, pushes, and pull requests
If policy blocks an action, emit a policy_decision activity with enough context for the user to understand what happened.
Emit activities
Use activities for progress that belongs in the dashboard or audit log:
progressplan_updatedexternal_url_updatedawaiting_inputcompletedfailedpolicy_decision
Use session metadata updates for plan text, external URLs, input requests, and error messages. Keep activity text concise and tied to one change in the run.
Finish the claim
End each claimed session in one of three ways:
- Complete when the work succeeds.
- Fail when the worker cannot finish and the session should be marked as errored.
- Release when the worker stops before doing useful work or decides another eligible worker should retry.
If the process exits unexpectedly, the claim lease and worker heartbeat let One Horizon mark the session or worker stale.
Retry with backoff
When a run fails and may succeed later, retry with exponential backoff instead of reporting a final failure immediately.
Delay before each retry attempt:
min(10000 × 2^(attempt − 1), max_retry_backoff_ms)The default backoff cap is 5 minutes. agent.max_retry_attempts in the workflow config defaults to 0. Once retries are exhausted, report a final failure so the platform can apply the configured task update for that outcome.
Workflow file configuration
If your worker uses a WORKFLOW.md file, the following fields apply when connected to One Horizon.
Supported
| Field | Notes |
|---|---|
workspace.* | Repository folder, workspace mode, and working directory. |
hooks.* | All four hook points are supported. |
agent.max_concurrent_agents | Global concurrency cap for this worker. |
agent.max_retry_backoff_ms | Maximum delay between retries. |
agent.max_retry_attempts | Maximum retries before reporting final failure. Default is 0. |
codex.command | The command to run. Only codex app-server is accepted. |
codex.turn_timeout_ms | Maximum time allowed for one execution turn. |
codex.stall_timeout_ms | Inactivity timeout. Values ≤ 0 disable stall detection. |
polling.interval_ms | How often to poll for sessions. Default is 30 seconds. |
Ignored
These fields have no effect when a worker connects to One Horizon. The platform manages them instead.
| Field | Reason |
|---|---|
tracker.* | One Horizon owns the tracker integration. |
codex.approval_policy | Policy is delivered in the session's trusted instructions. |
thread_sandbox | Policy is delivered in the session's trusted instructions. |
turn_sandbox_policy | Policy is delivered in the session's trusted instructions. |
agent.max_concurrent_agents_by_state | One Horizon uses a flat concurrency cap. |
Workflow files support hot reload. Changes take effect on the next poll cycle without restarting the worker.
Template variables
WORKFLOW.md content may include template variables. One Horizon resolves these at session claim time.
| Variable | Value |
|---|---|
{{ issue.id }} | Internal task UUID. |
{{ issue.identifier }} | Human-readable identifier such as T-42. |
{{ issue.title }} | Task title. |
{{ issue.description }} | Task description, if present. |
{{ issue.state }} | Current task state label. |
{{ issue.labels }} | Comma-joined label names. |
{{ issue.prompt }} | User prompt from the session. |
{{ attempt }} | Empty on the first run; retry number on subsequent attempts. |
Unknown variables are left in place unchanged rather than causing an error.
Minimal worker loop
The implementation loop is the same whether your runtime starts one process or keeps a daemon running.
flowchart TD
Auth["User OAuth"]
Register["Register worker"]
Heartbeat["Heartbeat"]
Poll["Poll sessions"]
Claim["Claim session"]
Execute["Execute locally"]
Finish{"Finish"}
Complete["Complete"]
Fail["Fail"]
Release["Release"]
Auth --> Register --> Heartbeat --> Poll --> Claim --> Execute --> Finish
Finish --> Complete
Finish --> Fail
Finish --> Release
Complete --> Heartbeat
Fail --> Heartbeat
Release --> Heartbeatload local configauthenticate with user OAuthagent = find or create agent profileworker = register or reuse local worker while running: heartbeat(worker) if heartbeat returns 404: clear saved worker ID exit signal = poll_worker_record(worker) if signal == "stop": finish current session, ack(signal), exit if signal == "restart": ack(signal), restart process or exit if respawn is unsupported if signal == "pause": ack(signal), suspend dispatch until "resume" if signal == "resume": ack(signal), resume dispatch sessions = poll_sessions(worker, status=["queued", "pending", "stale"]) for session in sessions: claim = claim_session(session, worker, leaseSeconds=900) if claim failed (409): continue attempt = 0 while attempt <= max_retry_attempts: try: payload = build_payload( trusted_instructions=claim.session.trustedInstructions, untrusted_context=claim.session.untrustedContext ) result = run_local_agent(payload, local_policy) emit_activity(session, claim.claimId, "completed") complete(session, claim.claimId, result) break catch needs_user_input: emit_activity(session, claim.claimId, "awaiting_input") patch_session(session, claim.claimId, status="awaiting_input") break catch blocked_by_policy: emit_activity(session, claim.claimId, "policy_decision") fail(session, claim.claimId, reason) break catch retryable_error: attempt += 1 if attempt > max_retry_attempts: emit_activity(session, claim.claimId, "failed") fail(session, claim.claimId, reason) else: delay = min(10000 * 2^(attempt - 1), max_retry_backoff_ms) wait(delay) catch error: emit_activity(session, claim.claimId, "failed") fail(session, claim.claimId, reason) break
API reference
The agent work API lives under:
/api/v1/workspaces/{workspaceId}/agentsUse the API reference for exact request bodies, response bodies, operation names, generated SDK types, and status enums.
For lifecycle details, read Sessions and claims.