Triggering botmux Tasks Programmatically via API
Let an external system (task orchestrator, CI, backend service, etc.) hand an instruction to a botmux bot over an HTTP API and get the final result back — the bot runs its CLI in a Feishu topic as usual, but the whole call is a pure programmatic "request → result", optionally with zero Feishu noise.
This doc is for caller-side developers: two execution modes, the four-state polling contract, auth, cancellation, and failure recovery — with copy-pasteable curl and client pseudocode.
1. Two execution modes
Every call hits the same endpoint POST /api/trigger; the difference is only in options:
Both modes open a "virtual session" (when no chatId is given) that never enters a Feishu group and posts nothing — a pure HTTP request/response.
⚠️ Production-grade dispatch should use async mode: in sync mode the
sessionIdis only returned on completion, so you cannot obtain it mid-run to cancel, and the HTTP connection blocks up to 5 minutes. Async mode returnssessionIdimmediately — pollable, cancelable, recoverable.
2. Authentication
Calls go through the dashboard (default http://<daemon-host>:7891). Auth currently uses a short-lived dashboard token:
- Programmatic calls MUST send the token as a Cookie header:
Cookie: botmux_dashboard_token=<TOKEN> - ⚠️ Do NOT use
?t=<TOKEN>query: that's for browser login; aPOSTcarrying it returns a 302 redirect (set-cookie) and the call fails. - Getting a token: run
botmux dashboardto print the login URL — the part after?t=is the token. Each run rotates it (the old token is invalidated).
The token is short-lived. Long-lived API-key auth (e.g.
X-Botmux-Api-Key) is planned; this doc will be updated when it lands.
3. Triggering a task
Request body
Hard constraints (all validated; violations return 400):
envelope.trustedmust befalse. This is an injection-defense design:trusted:falsedeclares "the envelope content is untrusted external data", so the daemon wraps it as an untrusted event and does not execute instructions embedded inside it. Put what you actually want the bot to do in the top-levelinstruction(the trusted directive), not in the envelope.- Omitting
chatIdrequiresoptionsto contain eitherwaitForFinalOutputorasyncReturnSessionId, elsetarget_required. options.timeoutMsrange[1000, 300000](1s–5min); out of range returns 400. Defaults to 120000.
Sync mode (waitForFinalOutput)
Response (HTTP 200, one-shot, result in output.content):
A sync-mode timeout (waited longer than
timeoutMs) returns HTTP 504 +errorCode:"wait_timeout". The task is in fact still running in the background — only this HTTP call ended. If you kept thesessionId, query it later as a fallback; don't treat it as a failure.
Async mode (asyncReturnSessionId)
Response (HTTP 200, returns immediately — keep target.sessionId as the correlation key):
4. Polling for the result (four-state contract)
In async mode, poll by sessionId:
All four states return HTTP 200 + ok:true; read task state from the state field only — do NOT judge by ok or the HTTP status.
completed example:
failed is a soft terminal — don't kill immediately
failed (no_output) means "the session terminated without a captured final output". It could be a genuine failure OR a cancel (close) you initiated — the two are indistinguishable from this signal. Recommended:
- Decide cancellation from your own intent (e.g. you recorded the cancel when you issued it); do not infer "was this my cancel?" from this
failed. - Treat
failedas "needs reconciliation": flag it, confirm there really is no output and it wasn't your own cancel, then commit the final failed state.
The two physical forms of not_found
Callers go through the dashboard proxy, so not_found surfaces two ways — normalize both to a not_found terminal:
HTTP 404+{ "ok": false, "error": "unknown_session" }— proxy short-circuit (the sessionId was never seen by the aggregator, usually an invalid/expired id).HTTP 200+{ "ok": true, "state": "not_found" }— the request reached the daemon but no session record exists on disk.
5. Restart-survival guarantee (important)
After a daemon restart, a task that already completed still returns completed (with output.content) — it is NOT misreported as not_found.
Under the hood, the async result is persisted to disk on completion (data/async-triggers/<sessionId>.json), and polling reads the persisted result first rather than in-memory state. Therefore:
- Your recovery logic must not treat a single missed lookup as a lost task.
- Only "the proxy confirms unknown (
unknown_session)" plus "your own lease/timeout also expired" should trigger compensating logic.
This is the foundation of async-mode failure recovery — even if the daemon restarts mid-poll, a completed result is not lost.
6. Cancelling a task
In async mode, cancel via close:
closemeans close the whole session, not "interrupt the current turn". For a one-shot virtual async session (one session, one turn) the two are equivalent.
After cancelling, polling that sessionId returns state:"failed" (no_output) if it had no output before closing. This is expected — commit your own cancelled terminal per your recorded intent; you don't need to rely on this failed.
7. Client pseudocode
Robustness notes (all from the tested contract):
- Clamp
timeoutMsto[1000, 300000]before sending. - In sync mode, treat
504/wait_timeoutas "possibly still running"; keepsessionIdfor a fallback query, don't kill it. - Sort polls into 5 classes; never conflate "retryable" with "terminal": not_found (404 unknown_session /
state:not_found) and error (400 request error, 401/403 auth) are terminal; unknown (network/timeout/5xx/502/non-JSON) is retryable — the task may still be running, and treating it as missing + re-dispatch causes double execution. Wrap bothfetchandres.json()in try/catch so an exception never bubbles up and breaks the poll loop.
8. Known limitations
-
Async result files are not auto-reclaimed yet:
data/async-triggers/<sessionId>.jsongrows monotonically (intentional — deleting on session close would drop thecompletedresult and break restart-survival). The upside: even if the session record is later cleaned up,completedis still queryable as long as the file exists; the cost is unbounded accumulation. A conservative TTL sweep (clean only after N days completed) is planned; this doc will be updated then. -
output.contentmay rarely carry a preamble: botmux already steers the model at the source (the HTTP-response-mode prompt) to "output only the final answer, no preamble/meta-commentary", so the vast majority of replies are clean. But this is prompt-level guidance, not a hard guarantee — an occasional preamble line can still slip through. If you renderoutput.contentdirectly to users and need it "guaranteed clean", add a conservative trim at the presentation layer as a fallback:- ✅ Strip only known, deterministic preamble prefixes (e.g. match fixed patterns like
This is a system routing header…/here's my answer:, and keep everything after the match). - ❌ Do NOT use aggressive extraction like "take the last non-empty paragraph" —
output.contentcan be legitimately multi-paragraph (bulleted answers, code blocks), and aggressive extraction would drop the real body, a far worse correctness bug than an occasional preamble. Prefer showing a full answer with one stray preamble line over losing the body. - Trim at the presentation layer only; persist/audit/replay the raw
content.
- ✅ Strip only known, deterministic preamble prefixes (e.g. match fixed patterns like
Appendix: endpoint cheatsheet
Key errorCodes: target_required, bad_request (incl. trusted check, timeoutMs out of range), bot_not_found, bot_not_in_chat, wait_timeout, no_output, session_not_found.
