Smoke testing the remote worker contract¶
This guide walks you through validating Trimus's remote worker
protocol end-to-end using nothing more than curl and jq. No
worker binary required.
Run it the first time you bring up a fresh Trimus install to confirm the wire is wired correctly, after an upgrade to confirm the contract didn't drift, or when you're building a new worker (openclaw, openfang, hermes, custom) and want to know which step you broke.
The wire-level protocol reference lives at REMOTE_WORKERS.md and the canonical operating prompt is served at
GET /api/workers/operating-prompt. This guide is the executable companion — it walks the same protocol the prompt documents, one HTTP call at a time, with expected results at each step.
What you'll exercise¶
By the end of this guide you will have confirmed, in order:
- Registration — a fresh worker can register itself and receives a one-time API key plus the operating prompt embedded in the response.
- Auth — bearer-token authentication works on the worker-only endpoints.
- Heartbeat — the worker can mark itself online and the server reflects that status.
- Operating prompt — the authenticated re-fetch endpoint serves the same canonical contract that registration ships.
- Empty claim — claiming when there's no work returns
204 No Contentcleanly. - Real claim cycle — a remote-adapter agent's heartbeat enqueues a task, the worker claims it, the payload has full v1.1 parity (manager, roster, timeline, project, labels), the worker completes it with action lines in the summary, and Trimus's parser applies those actions to the issue.
- Failure path —
POST /failsurfaces an unrecoverable error and the agent's heartbeat run is correctly marked failed.
If every section ends with the expected result you see in this doc, the protocol is healthy. If any one diverges, the section's troubleshooting subsection tells you where to look.
Prerequisites¶
- A running Trimus server you have admin access to. Locally this
is usually
docker compose upplus an admin login athttp://localhost:3100. curlandjqinstalled.- Three environment variables exported in your shell:
export TRIMUS_BASE_URL="http://localhost:3100"
export TRIMUS_ADMIN_API_KEY="…" # personal API key for the admin user
export COMPANY_ID="…" # ID of the company you'll create the test agent in
The admin API key is created from your user profile page → API
Keys. The company ID is visible in any company-scoped URL, or via
curl -s -H "Authorization: Bearer $TRIMUS_ADMIN_API_KEY" "$TRIMUS_BASE_URL/api/companies" | jq.
TRIMUS_ADMIN_API_KEY is used for setup (creating the test agent,
triggering its heartbeat). The worker API key is what we'll obtain
in Phase 1 and use for the worker-only endpoints. Don't mix them.
Requires trimus-server v3.1.0 or newer
Before v3.1.0 the authenticated worker endpoints were nested behind the
agent BearerAuth group, so every worker key was rejected with
401 {"error":"invalid api key"} — Phase 2 (and everything after) would
fail no matter what. If Phase 2 returns 401 on a current key, check
your server version first.
Phase 1 — Register as a worker¶
REG=$(curl -s -X POST "$TRIMUS_BASE_URL/api/workers/register" \
-H "Content-Type: application/json" \
-d '{"name": "smoke-test-worker", "tags": ["smoke"]}')
echo "$REG" | jq '{worker_id, api_key: (.api_key|.[0:8] + "…"), prompt_version, prompt_chars: (.operating_prompt|length)}'
export WORKER_ID=$(echo "$REG" | jq -r '.worker_id')
export WORKER_KEY=$(echo "$REG" | jq -r '.api_key')
What it exercises: the unauthenticated registration endpoint
(POST /api/workers/register), the one-time API key issuance, and the
embedded operating-prompt payload that workers consume on startup.
Expected output:
{
"worker_id": "…",
"api_key": "sk_xxxx…",
"prompt_version": "1.4.0",
"prompt_chars": <a few thousand>
}
The prompt_chars figure should be in the thousands — that's the full
operating contract. If it's zero or a few hundred, something went
wrong serialising the embedded prompt.
Troubleshooting:
401/403— the endpoint should be unauthenticated. If you get an auth challenge, your reverse proxy or middleware is forcing auth on/api/workers/register. The Trimus route isPostoutside theWorkerAuthgroup; checkcmd/server/routes.go.- Empty
operating_promptfield — the embedded prompt failed to serialise; check the server logs. prompt_versionolder than your server'sRemoteWorkerPromptVersion(currently1.4.0) — an older payload may lack full parity (manager/roster/timeline//create-issuebody); upgrade the server. The exact value isn't what matters — Phase 3 below must report the same version.
Phase 2 — Heartbeat¶
curl -s -X POST "$TRIMUS_BASE_URL/api/workers/heartbeat" \
-H "Authorization: Bearer $WORKER_KEY" \
-w "\nHTTP %{http_code}\n"
What it exercises: worker bearer-auth middleware and the
UpdateRemoteWorkerSeen side effect that flips the worker to
online.
Expected output:
Confirm by listing workers (admin auth):
curl -s -H "Authorization: Bearer $TRIMUS_ADMIN_API_KEY" \
"$TRIMUS_BASE_URL/api/workers" |
jq '.[] | select(.id == "'$WORKER_ID'") | {name, status, last_seen_at, tags}'
You should see "status": "online" and a recent last_seen_at. If
the worker shows offline, the WorkerAuth middleware didn't fire
its side effect — likely a bad bearer token. Check Phase 1 captured
the raw key (not a hashed display value).
Phase 3 — Operating prompt endpoint¶
curl -s -H "Authorization: Bearer $WORKER_KEY" \
"$TRIMUS_BASE_URL/api/workers/operating-prompt" |
jq '{version, prompt_starts_with: (.prompt|.[0:48])}'
What it exercises: the re-fetch endpoint that an onboarded worker hits when it needs the contract again after a server upgrade. Since the
148 hardening it sits behind worker auth — **pass your worker key as a¶
bearer token** (an anonymous fetch gets 401).
Expected output:
{
"version": "1.4.0",
"prompt_starts_with": "# Trimus Remote Worker Operating Contract\n\nYou are a"
}
If the version returned here differs from the prompt_version you
captured in Phase 1, something is very wrong: the registration
response embeds the same constant the endpoint serves.
Phase 4 — Claim with no work pending¶
curl -s -X POST "$TRIMUS_BASE_URL/api/workers/tasks/claim" \
-H "Authorization: Bearer $WORKER_KEY" \
-H "Content-Type: application/json" \
-d '{"tags": ["smoke"]}' \
-w "\nHTTP %{http_code}\n"
What it exercises: the empty-queue response path. A well-behaved
worker polls this endpoint on an idle loop and must treat 204
No Content as "nothing to do; sleep and try again."
Expected output:
If you get 200 with a task body, you have leftover work from a
prior test. That's not a failure of this step, but the smoke test
proceeds more cleanly from a clean queue.
Phase 5 — Real claim cycle¶
This is the main event. We'll create a one-shot test agent with
adapter_type = "remote" and the tag smoke, assign it an issue,
trigger its heartbeat, and walk the full worker cycle.
5a. Create the test agent¶
AGENT=$(curl -s -X POST "$TRIMUS_BASE_URL/api/companies/$COMPANY_ID/agents" \
-H "Authorization: Bearer $TRIMUS_ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "smoke-remote-agent",
"role": "general",
"adapter_type": "remote",
"adapter_config": {
"tags": ["smoke"],
"local_adapter_type": "llm",
"timeout_seconds": 600
},
"system_prompt": "You are a smoke-test agent. Acknowledge work and mark it done.",
"title": "Smoke Test Agent",
"description": "Disposable agent used by the remote-worker smoke test."
}')
export AGENT_ID=$(echo "$AGENT" | jq -r '.id')
echo "agent: $AGENT_ID"
Why: the worker's claim is filtered by tags; the agent's
adapter_config.tags = ["smoke"] matches the smoke tag we
registered with in Phase 1. Without a matching agent the worker
would correctly keep claiming nothing.
5b. Open an issue assigned to the agent¶
ISSUE=$(curl -s -X POST "$TRIMUS_BASE_URL/api/companies/$COMPANY_ID/issues" \
-H "Authorization: Bearer $TRIMUS_ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"title\": \"Smoke test — confirm worker can complete tasks\",
\"body_markdown\": \"## Steps\n1. Acknowledge in a comment\n2. Mark this issue done\",
\"status\": \"todo\",
\"priority\": \"medium\",
\"assignee_agent_id\": \"$AGENT_ID\"
}")
export ISSUE_ID=$(echo "$ISSUE" | jq -r '.id')
echo "issue: $ISSUE_ID"
5c. Trigger the agent's heartbeat¶
The issue has an assignee, so trigger the agent through the issue:
curl -s -X POST "$TRIMUS_BASE_URL/api/issues/$ISSUE_ID/trigger-agent" \
-H "Authorization: Bearer $TRIMUS_ADMIN_API_KEY" \
-w "\nHTTP %{http_code}\n"
Expected: HTTP 201 with the new heartbeat‑run JSON. The trigger
enqueues a heartbeat; the engine sees adapter_type = "remote", calls
into the remote adapter, which inserts a row into remote_tasks with
our tags and starts polling for a worker to claim it.
(Alternatively, trigger by agent: POST
/api/agents/$AGENT_ID/heartbeat/invoke.)
5d. Claim the task¶
TASK=$(curl -s -X POST "$TRIMUS_BASE_URL/api/workers/tasks/claim" \
-H "Authorization: Bearer $WORKER_KEY" \
-H "Content-Type: application/json" \
-d '{"tags": ["smoke"]}')
# 204 returns empty body; if you get an empty TASK, the engine hasn't
# enqueued yet — wait 1-2 seconds and try the claim again.
[ -z "$TASK" ] && { echo "no task yet; retry the claim in a moment"; }
export TASK_ID=$(echo "$TASK" | jq -r '.id')
echo "task: $TASK_ID"
5e. Validate the v1.1 payload — the heart of this smoke test¶
echo "$TASK" | jq '{
agent_name: .payload.agent_name,
agent_role: .payload.agent_role,
manager_name: .payload.manager_name,
roster_count: (.payload.roster // [] | length),
has_issue: (.payload.issue != null),
issue_title: .payload.issue.title,
project_name: .payload.issue.project_name,
labels: .payload.issue.labels,
timeline_count: (.payload.issue.timeline // [] | length),
system_prompt_chars: (.payload.system_prompt | length)
}'
What it exercises: full payload parity with the in-process adapter. The worker should be able to construct a full agent prompt from this one payload — no follow-up API calls required.
Expected output (the agent has no manager and no labels in this minimal setup, so those are null/empty — that's correct):
{
"agent_name": "smoke-remote-agent",
"agent_role": "general",
"manager_name": null,
"roster_count": 0,
"has_issue": true,
"issue_title": "Smoke test — confirm worker can complete tasks",
"project_name": null,
"labels": null,
"timeline_count": <0 or 1+>,
"system_prompt_chars": <a few dozen>
}
What to look for:
agent_nameandsystem_prompt_charsmust be populated. Ifsystem_prompt_charsis zero, the agent's persona didn't ship — the worker has nothing to feed as its model's system message.has_issuemust betrue. If it'sfalse, the wakeup didn't associate the issue.timeline_countshould be at least0and is populated when the issue has comments or activity. The field's presence is the contract — full parity surfaces these. To force a positive number, add a comment to the issue between Phases 5b and 5c:
curl -s -X POST "$TRIMUS_BASE_URL/api/companies/$COMPANY_ID/issues/$ISSUE_ID/comments" \
-H "Authorization: Bearer $TRIMUS_ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"body": "Pre-claim comment for timeline coverage."}'
Then re-do 5c and 5d. The new timeline_count should be ≥ 1 and
the comment body should appear when you inspect it:
- If you want to exercise
roster, make this agent atriagerole instead ofgeneral. Roster is populated only for routing roles. - If you want to exercise
manager_name/project_name/labels, addreports_to,project_id, and label associations to the agent / issue. The protocol fully supports all four — they're absent here only because the minimum-viable smoke test doesn't need them.
Troubleshooting:
- All v1.1 fields missing (
manager_name,roster,timeline,project_name,labelsall absent from JSON) — your server is a stale contract. The contract you're testing against is the v1.1 one; upgrade. timelinepresent but empty — fine, the issue has no comments/activities yet. The field shape is what matters.- Field names are CamelCase (
AgentNameetc.) — your server is serialising Go field names instead of the documented JSON keys; you're on an older binary.
5f. Complete the task¶
The summary field carries the model's response — action lines on
their own line are extracted by Trimus's parser and applied to the
issue.
SUMMARY='I have completed the smoke test work.
The issue body asked for two things: acknowledge in a comment (this is the comment) and mark the issue done (below).
/status done'
curl -s -X POST "$TRIMUS_BASE_URL/api/workers/tasks/$TASK_ID/complete" \
-H "Authorization: Bearer $WORKER_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg s "$SUMMARY" '{
summary: $s,
model: "smoke-test-model",
input_tokens: 42,
output_tokens: 17,
cached_tokens: 0,
cost_usd: 0.0001
}')" \
-w "\nHTTP %{http_code}\n"
Expected: HTTP 204.
5g. Verify the state propagated¶
The Trimus engine consumes the summary, extracts the /status done
action, and updates the issue.
sleep 2 # give the polling adapter a moment to ingest the result
curl -s -H "Authorization: Bearer $TRIMUS_ADMIN_API_KEY" \
"$TRIMUS_BASE_URL/api/companies/$COMPANY_ID/issues/$ISSUE_ID" |
jq '{status, title, comments: (.comments // [] | length)}'
Expected output:
The status flipped from todo to done (parser extracted
/status done). The prose part of the summary became a comment on
the issue. That's the full round-trip working.
Troubleshooting:
statusstayedtodo— the parser didn't see your/status doneline. Common causes: it was indented, wrapped in code fences, or not on its own line. Re-read §5 of the operating prompt.- No new comment was created — the action lines were the only thing
in your
summary. Trimus keeps the action lines AND the prose; if there's no prose, you get an empty-bodied comment with the actions applied. Add a sentence of prose if you want a visible comment.
Phase 6 — Failure path¶
Trigger a fresh heartbeat (same as Phase 5c), claim it (same as 5d), then fail it instead of completing.
# Re-trigger the agent and claim the new task.
curl -s -X POST "$TRIMUS_BASE_URL/api/issues/$ISSUE_ID/trigger-agent" \
-H "Authorization: Bearer $TRIMUS_ADMIN_API_KEY" > /dev/null
sleep 1
TASK=$(curl -s -X POST "$TRIMUS_BASE_URL/api/workers/tasks/claim" \
-H "Authorization: Bearer $WORKER_KEY" \
-H "Content-Type: application/json" -d '{"tags": ["smoke"]}')
export TASK_ID=$(echo "$TASK" | jq -r '.id')
# Fail it with a clear, short error message.
curl -s -X POST "$TRIMUS_BASE_URL/api/workers/tasks/$TASK_ID/fail" \
-H "Authorization: Bearer $WORKER_KEY" \
-H "Content-Type: application/json" \
-d '{"error": "smoke-test simulated failure: model API returned 503"}' \
-w "\nHTTP %{http_code}\n"
Expected: HTTP 204, and the corresponding heartbeat run in the
Trimus UI (under the agent's Runs tab) shows status failed with
the error message you provided.
Why this matters: a real worker that crashes mid-task or hits a
transient provider outage must call /fail so Trimus records a
real failure rather than waiting out the full timeout_seconds. The
prose error message becomes the run's failure reason and is visible
to operators triaging the agent.
Cleanup¶
# Delete the test agent (cascades to its issues and any remaining tasks).
curl -s -X DELETE \
-H "Authorization: Bearer $TRIMUS_ADMIN_API_KEY" \
"$TRIMUS_BASE_URL/api/companies/$COMPANY_ID/agents/$AGENT_ID" \
-w "\nHTTP %{http_code}\n"
# Optional: also remove the smoke-test worker so it doesn't clutter
# /admin/workers. Easiest: the Delete button on the Remote Workers admin
# page (Sidebar -> Remote Workers). There is no public DELETE on
# /api/workers; for scripting, delete the row directly:
# psql: DELETE FROM remote_workers WHERE id = '<worker_id>';
Common pitfalls¶
- "Smoke worker keeps showing offline." The
WorkerAuthmiddleware updateslast_seen_atonly when a bearer-authed call succeeds. HitPOST /api/workers/heartbeatfrom your worker on a loop (every ~30s in production) — without it, Trimus eventually flips your worker to offline even though it's reachable. - "Task never gets claimed." The agent's
adapter_config.tagsmust be a subset of the worker's registered tags. A worker registered with["smoke"]can claim agents with["smoke"]or[]— but not agents with["smoke", "claude"]because the worker lacksclaude. - "Claimed payload missing fields I'm sure should be there."
Most v1.1 fields are populated only when the source data exists.
No project on the issue →
project_nameis null. No labels →labelsis null/missing (omitempty). The field's absence is not a contract violation; the field's wrong shape (e.g. CamelCase keys) is. - "Action commands in my summary aren't being applied." They must be on their own line, not indented, not inside a code fence in the summary text. Trimus's parser is line-based and intentionally conservative — anything ambiguous is preserved as prose.
What "passing" looks like¶
If you walked all six phases and saw the expected outputs at each step, you've validated:
| Concern | Validated by |
|---|---|
| Registration issues a one-time API key + embeds the prompt | Phase 1 |
| Bearer-auth on worker-only routes works | Phase 2 |
| Worker-auth operating-prompt re-fetch serves the canonical text | Phase 3 |
| Empty claim returns 204 cleanly | Phase 4 |
| Tag-based dispatch routes tasks to the right worker | Phase 5d |
| Payload carries v1.1 parity fields (timeline, roster, …) | Phase 5e |
| JSON keys are stable lowercase snake_case | Phase 5e |
| Summary parsing applies action lines and preserves prose | Phase 5f / 5g |
| Fail path records an explicit, operator-readable error | Phase 6 |
That covers the full contract — anything a real worker implementation (openclaw, openfang, hermes, custom) needs to do.
Next steps¶
- Read the wire protocol reference at REMOTE_WORKERS.md.
- Read the operator deployment guide at remote-workers.md.
- The canonical operating prompt — which your worker should use as
its system prompt — is at
GET /api/workers/operating-promptor rendered on the/admin/workerspage.