Skip to content

Building a remote worker

A remote worker is a separate process that executes agent heartbeats outside the trimus-server process. The server stays the source of truth (scheduling, database, UI); the worker just claims tasks over HTTP, runs them, and posts results back. From an integrator's perspective this is how you make your code the thing that talks to LLM providers, instead of letting Trimus do it in-process.

For running the shipped trimus-worker binary, see the operator guide ../operators/remote-workers.md. This page is for writing a worker from scratch in any language. The canonical wire contract is REMOTE_WORKERS.md and the operating prompt served at GET /api/workers/operating-prompt — read those alongside this page.

When to write a custom worker

The shipped binary already runs llm, claude_local, and docker adapters. Write your own when:

  • You want custom execution logic that no built-in adapter covers (an in-house LLM gateway, a deterministic non-LLM responder).
  • You want strict tool/command restrictions on what the agent can do.
  • You want observability extensions — your own metrics on every claim/complete/fail.
  • You want an exotic runtime (Bun, Deno, Python, Rust — anywhere with HTTP + JSON).
  • You want to bridge Trimus to another task queue (Temporal, Cloud Tasks, …).

Don't roll your own if the shipped binary does the job.

The two ways a worker gets its key

There are two registration models, and they differ in who issues the key:

  1. Self-registration (custom workers). POST /api/workers/register is public and issues a fresh worker key in the response. A from-scratch worker calls it once at startup with no auth header, reads api_key from the response, and uses that for every subsequent call. This is the model the operating prompt and the smoke test document.

  2. Pre-provisioned key (the shipped Go binary). cmd/worker expects TRIMUS_WORKER_KEY to already exist in its environment and sends it as Authorization: Bearer on every call including register. An operator obtains that key out of band (the /admin/workers Register worker form, which mints the key and generates the env file, #335 — or a prior raw register call) and drops it in the worker's env file. Its register call adopts any key the server issues but otherwise keeps using the pre-provisioned one.

Either works. For a custom worker, model 1 is simplest: register, capture the key, loop. The rest of this page uses model 1.

Persist your key and re-present it — registration is idempotent

register is idempotent (server v3.1.0+): if you send a worker key the server already knows, it resumes that worker (same worker_id, HTTP 200, no new key) rather than creating a duplicate. So persist your api_key after the first registration and send it as the Authorization header on later register calls — otherwise a worker that re-registers from scratch on every restart leaves an orphan row behind each time.

What a worker does

register   → POST /api/workers/register                 (once, unauth — issues api_key)
loop {
  heartbeat → POST /api/workers/heartbeat               (every ~30s)
  claim     → POST /api/workers/tasks/claim             (when idle)
  if 200 with a task:
    do the work (call your LLM / subprocess / script)
    complete → POST /api/workers/tasks/{id}/complete    (or /fail)
  if 204:
    sleep 1-5s, claim again
}

Five endpoints total. After registration, every call carries Authorization: Bearer <api_key>.

Endpoint Auth Notes
POST /api/workers/register none Returns worker_id, api_key, operating_prompt, prompt_version.
POST /api/workers/heartbeat worker key Body {"worker_id":"…"}. 204 = acknowledged.
POST /api/workers/tasks/claim worker key Body {"tags":[…]} (optional). 204 = nothing to claim; 200 = a task.
POST /api/workers/tasks/{taskId}/complete worker key Body is a RemoteTaskResult. 204 = recorded.
POST /api/workers/tasks/{taskId}/fail worker key Body {"error":"…"}. 204 = recorded.

Minimal worker (Python pseudocode)

import time, requests

BASE = "https://trimus.example.com"

# 1. Register once. Public endpoint — no auth header. Save api_key durably;
#    it is shown exactly once. The response also embeds the operating
#    prompt (the worker contract) and its version — keep both.
reg = requests.post(f"{BASE}/api/workers/register",
                    json={"name": "my-custom-worker", "tags": ["python"]}).json()
api_key          = reg["api_key"]
operating_prompt = reg["operating_prompt"]   # the contract; use it as your model's system fragment
prompt_version   = reg["prompt_version"]     # "1.4.0" at time of writing
worker_id        = reg["worker_id"]

H = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
last_heartbeat = 0

while True:
    # Heartbeat roughly every 30s (stale workers — no heartbeat in 5 min — are
    # marked offline and skipped by the dispatcher).
    if time.time() - last_heartbeat > 30:
        requests.post(f"{BASE}/api/workers/heartbeat", headers=H,
                      json={"worker_id": worker_id})
        last_heartbeat = time.time()

    # Claim a task. The claim-body tags may only narrow within your
    # server-stored (registered) tag set — the stored set is authoritative
    # for matching (#336) and an operator can edit it on /admin/workers.
    claim = requests.post(f"{BASE}/api/workers/tasks/claim", headers=H,
                          json={"tags": ["python"]})
    if claim.status_code == 204:
        time.sleep(2)            # nothing to do
        continue

    task = claim.json()
    task_id = task["id"]
    try:
        result = run_the_agent(task["payload"])   # YOUR code; returns a RemoteTaskResult dict
        requests.post(f"{BASE}/api/workers/tasks/{task_id}/complete",
                      headers=H, json=result)
    except Exception as e:
        requests.post(f"{BASE}/api/workers/tasks/{task_id}/fail",
                      headers=H, json={"error": str(e)})

The task payload

A claimed task (200) is a RemoteTask. The fields you care about live under payload — it carries full parity with what an in-process adapter would have:

Field When Meaning
id (top level) always The {taskId} for complete/fail.
payload.run_id, agent_id, agent_name, agent_role always Run + agent identity.
payload.manager_name, manager_role when reports_to set The escalation target for /assign.
payload.company_id, company_name always Tenant identity.
payload.adapter_type, adapter_config always What adapter the agent was configured for (informational — you can run a different one).
payload.system_prompt always The agent's persona. Use it verbatim as your model's system message.
payload.wake_reason always Why the heartbeat fired (manual, schedule, wakeup: …).
payload.issue when an issue is assigned id, identifier, title, status, priority, body_markdown, project_name, labels, timeline.
payload.issue.timeline when there's history Ordered comments + activity-log entries — the conversation continuity.
payload.roster routing roles only (e.g. triage) Teammates this agent can /assign to: id, name, role, title, description.

Optional fields use omitempty — they're absent when there's no data (no project → no project_name; no labels → no labels). Their absence is normal; a wrong shape (e.g. CamelCase keys) would mean you're hitting a pre-v2.11.0 server.

What run_the_agent should produce

Your job is to turn the payload into a RemoteTaskResult:

{
  "summary": "<model response text, INCLUDING action lines>",
  "model": "claude-opus-4-7",
  "input_tokens": 12345,
  "output_tokens": 678,
  "cached_tokens": 0,
  "cost_usd": 0.0123
}

summary is required; token counts and cost_usd are optional but feed Trimus's budget tracking. The recommended flow:

  1. System message = payload.system_prompt.
  2. User message = render the issue context. Mirror Trimus's built-in heartbeat prompt structure:
  3. ## Youagent_name (agent_role); add a "Reports to: <manager_name>" line when manager_name is non-null.
  4. ## Team Roster — only when roster is non-empty.
  5. ## Your Assignment — identifier, title, status, priority, project, labels.
  6. ## Descriptionissue.body_markdown.
  7. ## Conversation History — every issue.timeline entry in order (comments verbatim; activities one-line-summarized).
  8. ## Available Actions — copy the action verbs (below) so the agent knows what it can emit.
  9. Call your model. Capture the response verbatim — that's summary.

Or skip the model entirely for a scripted agent and produce summary directly. The contract is HTTP/JSON, not LLM-specific.

Action verbs the engine parses out of summary

The agent expresses state changes as bare action lines in summary (not wrapped in JSON or code fences — the parser is line-based). The engine on the server side extracts and applies them; the remaining prose becomes a comment on the issue. The verbs in the worker contract:

  • /status done|in_progress|in_review|backlog|blocked
  • /priority critical|high|medium|low
  • /assign <agent-name> — reassign / escalate (must be a known name)
  • /release — return the issue to the unassigned pool
  • /request-approval <title> — pause for human approval
  • /create-issue <title> — spawn a child issue (indented assignee:, blocked-by:, blocks:, relates: continuation lines bind to it; an optional fenced block becomes the new issue's body)
  • /blocks <id-or-title> / /blocked-by <…> / /relates <…> — record dependency edges
  • /write-file <relative-path> + a fenced code block — persist a file into the issue's project workspace

(The engine's full verb set is larger; the worker contract advertises this subset. The authoritative list is §5 of the operating prompt.)

Tags

Tags are the routing mechanism. You register with a tag set; you can claim a task only when the agent's required tags are a subset of yours. A worker with ["smoke"] can claim agents requiring ["smoke"] or [], but not agents requiring ["smoke","claude"] (it lacks claude). Tasks with no required tags are claimable by any worker, including the in-process pool inside trimus-server. Required tags come from the agent's adapter_config.tags (see REMOTE_WORKERS.md).

Concurrency

The simplest worker is single-threaded — one task at a time, as above. For more throughput, claim multiple tasks (one per goroutine / thread / async task); each claim returns a separate task id. Don't claim more than you can actually run in parallel: a claimed task sits in claimed state and won't be re-dispatched until you complete/fail it or it times out.

Failure modes to handle

Network drop mid-task

You claimed a task but can't reach Trimus to post complete. The task stays claimed; after the heartbeat-timeout window (default ~5 min) Trimus reaps it and another worker can re-claim. Store enough state to retry, and treat a double-complete defensively (the re-claim may have completed first).

LLM provider failure

Prefer a short retry/backoff over an immediate /fail. If it's truly unrecoverable, POST /fail with the upstream error — that's better than completing with an empty summary or letting the task time out silently.

Process restart

On restart, re-present your persisted key — registration is idempotent, so you resume the same worker rather than spawning a new one (model 1); the shipped binary re-reads TRIMUS_WORKER_KEY (model 2). Any in-flight tasks from before the crash get reaped after the timeout — accept the loss, or persist task ids and decide whether to re-run or fail them.

Smoke-testing your worker

The operator smoke test walks the entire protocol with nothing but curl + jq — register, heartbeat, claim, validate the payload, complete with an action line, verify the issue updated, and exercise the fail path. Run it against your server to confirm the wire before pointing your worker at it: ../operators/remote-worker-smoke-test.md.

Worker vs plugin

A worker is a separate process that runs heartbeats; a plugin extends trimus-server in-process and exposes tools/webhooks/jobs. Pick a worker when you want to do the heartbeat work elsewhere, scale heartbeat capacity independently, or isolate execution. Pick a plugin (writing-a-plugin.md) when you want agents to call a tool or react to a webhook inside Trimus.

Where to next