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:

ModeHow to triggerWhen to useCancelable mid-run
Syncoptions.waitForFinalOutput=trueone-shot, short task (≤5 min), no mid-run cancel
Asyncoptions.asyncReturnSessionId=truefast dispatch + polling, needs cancel, needs recovery

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 sessionId is only returned on completion, so you cannot obtain it mid-run to cancel, and the HTTP connection blocks up to 5 minutes. Async mode returns sessionId immediately — 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; a POST carrying it returns a 302 redirect (set-cookie) and the call fails.
  • Getting a token: run botmux dashboard to 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

{
  "source":   { "type": "webhook" },                    // source type
  "target":   { "kind": "turn", "botId": "cli_xxx" },   // target bot's larkAppId
  "instruction": "the instruction for the bot (trusted; rendered as a top-level directive)",
  "envelope": {
    "format": "json",
    "sourceName": "your-system",                         // caller identity
    "trusted": false                                     // must be false, see below
  },
  "options": { /* see below */ }
}

Hard constraints (all validated; violations return 400):

  • envelope.trusted must be false. This is an injection-defense design: trusted:false declares "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-level instruction (the trusted directive), not in the envelope.
  • Omitting chatId requires options to contain either waitForFinalOutput or asyncReturnSessionId, else target_required.
  • options.timeoutMs range [1000, 300000] (1s–5min); out of range returns 400. Defaults to 120000.

Sync mode (waitForFinalOutput)

curl -X POST "http://<host>:7891/api/trigger" \
  -H 'content-type: application/json' \
  -H "Cookie: botmux_dashboard_token=$TOKEN" \
  -d '{
    "source":{"type":"webhook"},
    "target":{"kind":"turn","botId":"cli_xxx"},
    "instruction":"Reply with exactly one line: SYNC_DEMO_OK",
    "envelope":{"format":"json","sourceName":"demo","trusted":false},
    "options":{"waitForFinalOutput":true,"timeoutMs":60000}
  }'

Response (HTTP 200, one-shot, result in output.content):

{
  "ok": true,
  "triggerId": "trg_dcbd124a-...",
  "action": "completed",
  "target": { "kind": "turn", "sessionId": "0bc442ef-...", "chatId": "http_wait_..." },
  "output": { "content": "SYNC_DEMO_OK" },
  "message": "queued new session turn and completed"
}

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 the sessionId, query it later as a fallback; don't treat it as a failure.

Async mode (asyncReturnSessionId)

curl -X POST "http://<host>:7891/api/trigger" \
  -H 'content-type: application/json' \
  -H "Cookie: botmux_dashboard_token=$TOKEN" \
  -d '{
    "source":{"type":"webhook"},
    "target":{"kind":"turn","botId":"cli_xxx"},
    "instruction":"Reply with exactly one line: ASYNC_DEMO_OK",
    "envelope":{"format":"json","sourceName":"demo","trusted":false},
    "options":{"asyncReturnSessionId":true}
  }'

Response (HTTP 200, returns immediately — keep target.sessionId as the correlation key):

{
  "ok": true,
  "triggerId": "trg_87e7b415-...",
  "action": "queued",
  "target": { "kind": "turn", "sessionId": "2eed60c4-...", "chatId": "http_async_..." },
  "async": { "status": "pending", "sessionId": "2eed60c4-..." },
  "message": "queued new session turn; poll by sessionId or triggerId for final output"
}

4. Polling for the result (four-state contract)

In async mode, poll by sessionId:

GET /api/sessions/:sessionId/trigger-result
   (optional ?triggerId=<trg_...> to match a specific trigger; omitted → the session's latest)

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.

stateMeaningWhat to doKey fields
runningstill runningkeep polling
completedfinal output readyterminal; read output.contentoutput.content, finishedAt
failedsession terminated with no captured output (soft terminal)see belowerrorCode:"no_output", error, finishedAt
not_foundno such session (never existed / invalid id)see two physical forms belowerrorCode:"session_not_found"

completed example:

{
  "ok": true,
  "state": "completed",
  "triggerId": "trg_87e7b415-...",
  "output": { "content": "ASYNC_DEMO_OK" },
  "finishedAt": "2026-07-24T08:43:17.126Z",
  "target": { "kind": "turn", "sessionId": "2eed60c4-...", "chatId": "http_async_..." },
  "async": { "status": "completed", "sessionId": "2eed60c4-...", "completedAt": "..." }
}

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 failed as "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:

  1. HTTP 404 + { "ok": false, "error": "unknown_session" } — proxy short-circuit (the sessionId was never seen by the aggregator, usually an invalid/expired id).
  2. 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:

curl -X POST "http://<host>:7891/api/sessions/:sessionId/close" \
  -H "Cookie: botmux_dashboard_token=$TOKEN"
# → { "ok": true, "alreadyClosed": false }

close means 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

// Minimal skeleton: trigger + poll to a terminal state
async function runAndAwait(instruction: string, botId: string): Promise<Result> {
  // 1) async trigger, get sessionId
  const trg = await post('/api/trigger', {
    source: { type: 'webhook' },
    target: { kind: 'turn', botId },
    instruction,
    envelope: { format: 'json', sourceName: 'my-system', trusted: false },
    options: { asyncReturnSessionId: true },
  });
  const sessionId = trg.target.sessionId;

  // 2) poll, read state only
  for (;;) {
    const r = await getTriggerResult(sessionId); // classified below
    switch (r.state) {
      case 'running':   await sleep(3000); continue;
      case 'unknown':   await sleep(3000); continue; // retryable: network/timeout/5xx/non-JSON, task may still run
      case 'completed': return { ok: true, content: r.output.content };
      case 'failed':    return { ok: false, needsReconcile: true };  // soft terminal
      case 'not_found': return { ok: false, notFound: true };        // terminal: confirmed no such session
      case 'error':     return { ok: false, fatal: true, why: r.why }; // terminal: request/auth error, retrying won't help
    }
  }
}

// getTriggerResult sorts the response into 5 classes. The point is to never
// conflate "unknown/retryable" with "definitely terminal":
//  - not_found  : confirmed missing → terminal  ((a) 404 unknown_session; (b) 200 state:not_found)
//  - completed/running/failed : the daemon's four states, passed through
//  - error      : request error (400) / auth (401/403) → terminal, retry is pointless
//  - unknown    : network/timeout/5xx/502/non-JSON → retryable, task may still be running
async function getTriggerResult(sessionId: string) {
  let res: Response;
  try {
    res = await fetch(`/api/sessions/${sessionId}/trigger-result`, { headers: cookie() });
  } catch (e) {
    // fetch itself threw: unreachable / DNS / reset / timeout → retryable
    return { state: 'unknown', why: `network: ${String(e)}` };
  }

  // Auth error: token expired / not permitted → terminal (retry is refused too)
  if (res.status === 401 || res.status === 403) return { state: 'error', why: `auth ${res.status}` };

  // Proxy short-circuit / adapter 404
  if (res.status === 404) {
    const b = await res.json().catch(() => ({}));
    if (b?.error === 'unknown_session') return { state: 'not_found' }; // (a) confirmed missing
    if (b?.state === 'not_found') return { state: 'not_found' };       // (b) adapter-translated missing
    if (b?.state) return b; // adapter translated failed etc. to 404 → honor body.state (pass through)
    // ⚠️ Any other 404 (gateway/old-route HTML, non-JSON → parsed to {}) is NOT
    // a confirmed miss; treat as retryable unknown — otherwise a flaky gateway
    // gets misread as a lost task and triggers re-dispatch / double execution.
    return { state: 'unknown', why: 'opaque 404' };
  }

  // Request error, e.g. the 400 bad_request for a precise-triggerId miss → terminal
  if (res.status === 400) return { state: 'error', why: 'bad_request' };

  // 5xx / 502 daemon-unreachable → retryable (the task may still be running; never re-dispatch)
  if (res.status >= 500) return { state: 'unknown', why: `upstream ${res.status}` };

  // 2xx: parse JSON; non-JSON (gateway/proxy HTML etc.) → retryable unknown
  let body: any;
  try { body = await res.json(); } catch { return { state: 'unknown', why: 'non-json 2xx' }; }
  if (body?.state) return body; // { state, output?, errorCode?, finishedAt? }
  return { state: 'unknown', why: 'no state field' };
}

Robustness notes (all from the tested contract):

  • Clamp timeoutMs to [1000, 300000] before sending.
  • In sync mode, treat 504/wait_timeout as "possibly still running"; keep sessionId for 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 both fetch and res.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>.json grows monotonically (intentional — deleting on session close would drop the completed result and break restart-survival). The upside: even if the session record is later cleaned up, completed is 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.content may 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 render output.content directly 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.content can 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.

Appendix: endpoint cheatsheet

EndpointMethodPurpose
/api/triggerPOSTtrigger a task (sync/async chosen by options)
/api/sessions/:id/trigger-resultGETasync result polling (four states)
/api/sessions/:idGETsession metadata (status/title/etc.)
/api/sessions/:id/closePOSTcancel/close a session

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.