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:
-
Self-registration (custom workers).
POST /api/workers/registeris public and issues a fresh worker key in the response. A from-scratch worker calls it once at startup with no auth header, readsapi_keyfrom the response, and uses that for every subsequent call. This is the model the operating prompt and the smoke test document. -
Pre-provisioned key (the shipped Go binary).
cmd/workerexpectsTRIMUS_WORKER_KEYto already exist in its environment and sends it asAuthorization: Beareron every call including register. An operator obtains that key out of band (the/admin/workersRegister worker form, which mints the key and generates the env file, #335 — or a prior rawregistercall) 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:
- System message =
payload.system_prompt. - User message = render the issue context. Mirror Trimus's built-in heartbeat prompt structure:
## You—agent_name(agent_role); add a "Reports to:<manager_name>" line whenmanager_nameis non-null.## Team Roster— only whenrosteris non-empty.## Your Assignment— identifier, title, status, priority, project, labels.## Description—issue.body_markdown.## Conversation History— everyissue.timelineentry in order (comments verbatim; activities one-line-summarized).## Available Actions— copy the action verbs (below) so the agent knows what it can emit.- 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 (indentedassignee:,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¶
- REMOTE_WORKERS.md — full wire protocol + JSON schemas.
- ../operators/remote-workers.md — running a worker in production.
- ../operators/remote-worker-smoke-test.md — the executable protocol walkthrough.
- ../../../API.md#remote-workers — endpoint reference.