Skip to content
stage

Threads

Thread

Thread (T3 mirror row):

{
  "t3ThreadId": "thread_abc",
  "t3ProjectId": "proj_abc",
  "projectId": 1,
  "projectTitle": "stage",
  "title": "Say OK",
  "status": "completed",
  "settled": false,
  "settledAt": null,
  "runStartedAt": "2026-11-08T10:00:00Z",
  "runEndedAt": "2026-11-08T10:01:00Z",
  "runCount": 2,
  "pullRequests": [
    { "host": "github.com", "repository": "owner/repo", "number": 41, "url": "https://github.com/owner/repo/pull/41", "source": "created", "state": "merged" }
  ],
  "pendingRequestCount": 0,
  "pendingQuestionCount": 0,
  "providerInstanceId": "claude",
  "model": "claude-haiku-5-5",
  "runtimeMode": "full-access",
  "interactionMode": "default",
  "branch": null,
  "worktreePath": null,
  "parentThreadId": null,
  "relationship": null,
  "creationSource": "mcp",
  "source": "app",
  "taskId": 7,
  "t3WebUrl": "http://t3.example.com:3773/env_123/thread_abc",
  "t3AppUrl": "https://app.t3.codes/threads/env_123/thread_abc",
  "createdAt": "2026-11-08T10:00:00Z",
  "updatedAt": "2026-11-08T10:01:00Z",
  "syncedAt": "2026-11-08T10:01:05Z",
  "lastWordPreview": "Done. Fixed the race in PollTask and added a regression test.",
  "lastWordAt": "2026-11-08T10:00:58Z",
  "lastWord": null,
  "recentRuns": null
}
  • lastWord (detail reads only: GET /api/threads/:id thread and GET /api/tasks/:id thread; always null in lists): the thread's latest assistant reply (last word) as last synced, { "text": "## Done\nFixed **the race**.", "truncated": false, "runId": "run_2", "at": "2026-11-08T10:00:58Z" }; null until one is read. text is the reply's markdown capped at 4000 chars (T3 UTF-16 units); truncated = the reply is longer (full text in T3); runId = the reply's run; at = the reply item's updatedAt.
  • lastWordPreview (every Thread and Task): the same reply as one plain line (markdown markers dropped, whitespace collapsed, up to 240 chars); lastWordAt = lastWord.at.
  • Sync reads the last word only when a thread is at rest (no active run, or pending input) and its updatedAt changed since the last check, from any detail read (task polls, watched threads, root threads in full sync, linked and continuation sessions); a running thread keeps its previous reply. A thread with no assistant reply keeps the stored one.
  • projectId / projectTitle null when no app project mirrors t3ProjectId.
  • runStartedAt / runEndedAt: elapsed run span from the last detail read (t3_thread_read recentRuns, up to 50): the oldest run's startedAt (requestedAt if it never started; thread createdAt when runCount exceeds the runs read) to the newest run's completedAt; null without runs, runEndedAt null while the newest run is incomplete. Full sync reads every root thread, so subagents have no span.
  • pendingRequestCount: T3 pending requests (questions and approvals). pendingQuestionCount: how many of them are user-input questions stage can answer (t3_pending_request list, counted whenever sync reads a thread with pending requests; a failed count keeps the previous value; reset to 0 when nothing is pending; never above pendingRequestCount).
  • relationship: fork|subagent|null. creationSource: T3 web|mobile|mcp|provider|server.
  • pullRequests (PullRequest[], never null): PRs T3 associates with the thread, mirrored on every list and detail read (t3_thread_list / t3_thread_read linkedPullRequest first, then pullRequests, deduped by url, entries without a url dropped). repository is owner/name; state is T3's string (open, merged, closed, draft, ...); source is T3's (created, agent) or linked for an unlabelled linkedPullRequest. Fresh within the 5 min full sync (sooner for polled/watched threads).
  • runCount: runs T3 reported on the last detail read (each follow-up is a new run); 0 before one.
  • recentRuns (ThreadRun[]; detail reads only: GET /api/threads/:id thread and GET /api/tasks/:id thread; always null in lists): newest first, at most 50 (T3 runLimit), from the last detail read; [] before one. runCount can exceed its length; older runs live only in T3.

ThreadRun:

{ "runId": "run:thread:t1:ordinal:2", "ordinal": 2, "status": "completed", "providerInstanceId": "claudeAgent", "model": "claude-opus-5-5", "executedModel": "claude-opus-5-5", "requestedAt": "2026-11-08T10:00:00Z", "startedAt": "2026-11-08T10:00:01Z", "completedAt": "2026-11-08T10:01:00Z" }

status: ThreadStatus. model: requested; executedModel: what the provider ran (null when T3 did not report it). startedAt null while queued, completedAt null while running. Queue time = startedAt - requestedAt; run time = completedAt - startedAt.

GET /api/threads

Query (all optional):

ParamMeaning
projectapp project id
statusrepeatable ThreadStatus
staterepeatable normalized TaskState
active1|true: unarchived, unsettled running/needs-input sessions across origins, no implicit date cutoff
sortactivity: updatedAt desc; omitted: createdAt desc
parentdirect children of this T3 thread ID
settledtrue|false
providerproviderInstanceId
sourceapp|t3
qcase-insensitive title substring
from, tocreatedAt
subagentsinclude|exclude, default include; exclude = root threads only, parentThreadId null
needsInput1|true: only external threads counted by the badge (see Notification rules); other filters still apply
running1|true: only external running threads (see Notification rules); other filters still apply
settledFromISO date or datetime: only external settled threads with settledAt >= settledFrom, ordered by settledAt desc (see Notification rules); other filters still apply
limitdefault 100, max 500
offsetdefault 0

200 { "threads": Thread[], "total": number }; total = matches before limit/offset. Thread also includes state, archived, activeRunId and latestRunId. Session state follows live thread metadata independently of a legacy task's frozen state. Pending input, active runs and failed/interrupted/cancelled runs outrank a stale settled flag.

GET /api/threads/search

Query: q (required, trimmed, 2 to 200 characters), project (app project id, optional), limit (1..50, default 20; applies to each source).

Finds sessions by title, then by what was said in them. Two sources, run concurrently:

  1. stage: root threads whose title contains q (case-insensitive), newest activity first, up to limit.
  2. T3 t3_thread_search (only when connected, cancelled after 2s): active, unarchived, non-subagent threads by title, branch, PR and project, then message text; not exhaustive; up to limit.

Results: stage matches first, then T3 matches whose thread is not already listed, in T3's order (dedupe by thread id; a stage title match wins over a T3 message match).

200 { "results": SearchResult[], "t3": "ok"|"offline"|"timeout"|"error" }. T3 failures never fail the request: t3 other than ok means stage title matches only.

SearchResult:

{
  "t3ThreadId": "thread-a",
  "title": "Fix auth token refresh",
  "projectTitle": "stage",          // null when unknown
  "matchSource": "branch",          // title|branch|project|pr|user|assistant
  "snippet": null,                  // message text around the match (user|assistant only, <= 240 chars)
  "messageCreatedAt": null,         // ISO, user|assistant only
  "t3DeepLink": "t3code://threads/<env>/thread-a",
  "thread": {}                      // Thread, or null when stage has not synced it yet (session detail still opens it)
}

matchSource: stage matches are title. T3 reports title for title, branch, PR and project matches alike; stage labels them from its mirrored row: title contains q (or every word of it) → title, else branch → branch, else project title → project, else pr (PRs are not mirrored). An unsynced thread keeps title. T3 message matches are user or assistant.

400 invalid q/limit/project; 404 unknown project.

GET /api/threads/:id

200 { "thread": Thread, "children": Thread[], "continuations": Continuation[], "task": Task|null, "createdTasks": CreatedTasks }.

  • createdTasks: the tasks this session created (see Session batch; empty arrays when none).
  • Encode the complete T3 thread ID as one URL path segment.
  • Refreshes the requested session when connected; reads cached metadata offline.
  • Children are direct children, newest activity first.
  • Includes the session's continuation orders and history.
  • Never sends a message.

404 missing session; 502/503 T3 read errors.

Transcript

Read-only conversation of a session: user messages, assistant messages and plans (T3 t3_thread_read view messages). No tool calls, commands, reasoning or diffs; those stay in T3. Encode the T3 thread ID and item ID as single path segments. All endpoints: 404 when the session is not mirrored, 503 when T3 is not connected, 502 on T3 read errors.

TranscriptItem:

{
  "position": 12,
  "itemId": "item_abc",
  "sourceThreadId": "thread_abc",
  "inherited": false,
  "kind": "assistant",
  "status": "completed",
  "streaming": false,
  "createdBy": "agent",
  "runId": "run_1",
  "text": "Done. Summary...",
  "truncated": false,
  "updatedAt": "2026-11-08T10:01:00Z"
}
  • position: T3 global timeline position, unique per item, ascending in time (tool items in between are skipped, so positions have gaps).
  • kind: user|assistant|plan.
  • inherited: the item belongs to a parent thread (fork history).
  • status: T3 turn item status (idle|pending|running|waiting|completed|failed|cancelled|interrupted).
  • streaming: the item belongs to the active run and is still pending/running/waiting, so its text may grow.
  • createdBy: user|agent|system|null.
  • text: at most 2000 characters; truncated true when the full text is longer (fetch it from the item endpoint).
  • Items are upserts keyed by position: a newer copy (same or later updatedAt) replaces the older.

Cursor: the highest position at or below which every message is final (not streaming). Resuming from a cursor resends everything after it, including streaming items.

GET /api/threads/:id/transcript

Query: limit (default 50, max 200), before (position; only items below it).

200 { "items": TranscriptItem[], "hasEarlier": boolean, "cursor": number|null, "active": boolean, "status": ThreadStatus }.

  • items ascending by position: the newest limit messages (below before when given).
  • hasEarlier: older messages exist (page with before = first item's position).
  • cursor: resume point for the stream, null when before is set; -1 for an empty session.
  • active: a run is in flight (active run or status preparing/queued/starting/running/waiting).

T3 has no backward paging, so each page scans the session forward.

GET /api/threads/:id/transcript/items/:itemId

200 { "itemId": string, "text": string, "truncated": boolean }: the item's full text, assembled from T3 text pages; truncated only when it exceeds 1,000,000 characters.

GET /api/threads/:id/transcript/stream

Server-Sent Events (text/event-stream). Query after (cursor; default -1). A Last-Event-ID header (browser reconnect) wins over after. On connect the server sends retry: 3000, every item after the cursor, then a state event, then live changes. It never sends items at or below the cursor, and event ids never go below it.

EventdataMeaning
itemTranscriptItemnew or changed item (upsert). id: is the current cursor.
state{ "active": boolean, "status": ThreadStatus, "error": string|null }sent on connect and whenever activity, status or the T3 read error changes.
reset{}positions no longer line up, or the client fell behind. The server closes the stream; refetch the REST page and reconnect with its cursor.

Comment lines (: ping) every 15s keep proxies from timing out. Headers: Cache-Control: no-cache, no-transform, X-Accel-Buffering: no; every event is flushed. Caddy's reverse_proxy flushes event streams by default; keep this path out of encode if responses arrive in bursts. See Reverse proxy and SSE.

Polling: one server poller per session, shared by every open stream. While a run is active it reads T3 every 2s from the cursor (re-reading streaming items). After the run ends it reads once more, then stops calling T3 and watches the mirrored session status (refreshed by sync every 5s) to resume. It stops 10s after the last stream closes. Child (subagent) sessions are not refreshed by the watcher, so a new run in one goes live only after reconnecting.

Pending questions

A session waiting on its user has pending requests: user-input questions (T3 AskUserQuestion-style) and permission approvals. stage reads and answers questions through T3 t3_pending_request (needs orchestration:operate); approvals are only counted and must be resolved in T3.

Question (T3 user-input question):

{
  "id": "approach",
  "header": "Approach",
  "question": "Which rollout approach should I take?",
  "options": [{ "label": "Incremental", "description": "Run both loops, then switch", "value": "incremental" }], // value optional
  "multiSelect": false,        // optional
  "allowCustomAnswer": null,   // optional; free text allowed unless false
  "initialAnswer": null,       // optional; prefilled custom answer
  "required": null             // optional; stage, like T3's client, requires every question
}

GET /api/threads/:id/questions

Reads the thread and its pending questions fresh from T3 and refreshes the mirror (pendingRequestCount, pendingQuestionCount). 200:

{ "pendingRequestCount": 2, "approvalCount": 1, "requests": [{ "requestId": "req-1", "questions": [/* Question */] }] }

approvalCount = pending requests T3 does not list as questions. Requests answered between list and read are skipped. 404 unknown thread; 502 T3 error; 503 T3 not connected.

POST /api/threads/:id/questions/:requestId/answer

Body { "answers": { "<questionId>": string | string[] } }.

Rules (T3's client rules):

  • Every question answered, no unknown ids.
  • A string is an option (its value, or its trimmed label when it has no value) or custom text (not blank unless initialAnswer is set; only when allowCustomAnswer is not false).
  • An array is for multiSelect only, non-empty, known options, no duplicates.

stage re-reads the request, validates, sends the answers with labels normalized, records them, then refreshes the thread and polls its task (a task usually leaves needs_input at once).

200 { "requestId": string, "respondedAt": string, "thread": Thread }.

Idempotent: resending the answers stage already sent for a request returns the first respondedAt without calling T3 (also when two identical submits race). Errors:

StatuscodeWhen
400invalid answers (message names the question)
404unknown thread
409already_answeredanswered in T3, or by stage with different answers
409request_stalethe request is gone or no longer pending (e.g. expired)
502other T3 failure
503T3 not connected

Queued continuations

A continuation sends a saved prompt into an existing top-level, unarchived session. It never launches a replacement thread, steers or interrupts current work. Saving and resuming only check readiness; the background runner owns delivery.

Continuation:

{
  "id": 1,
  "afterThreadId": "thread-a",
  "targetThreadId": "thread-b",
  "previousContinuationId": null,
  "prompt": "Update the client against the finalized API.",
  "state": "waiting",
  "waitReasons": ["predecessor_unsettled", "target_running"],
  "lastError": null,
  "runId": null,
  "messageId": null,
  "createdAt": "2026-11-10T00:00:00Z",
  "updatedAt": "2026-11-10T00:00:00Z",
  "sentAt": null,
  "settledAt": null,
  "afterThread": {}, // Thread
  "targetThread": {} // Thread
}

States: waiting|paused|blocked|sending|running|awaiting_settle|settled|cancelled|uncertain.

Wait reasons: predecessor_unsettled|target_running|target_needs_input|predecessor_failed|target_failed|upstream_pending|upstream_cancelled|thread_missing|t3_offline|delivery_uncertain|legacy_conflict.

Endpoints

  • GET /api/continuations → { "continuations": Continuation[] }. Optional thread=<id> includes related orders, predecessor first, retaining history.
  • POST /api/continuations body { "afterThreadId": string, "targetThreadId": string, "prompt": string } → 201 Continuation. Required nonblank IDs/prompt. Prompt whitespace preserved. No send on save.
  • PATCH /api/continuations/:id body { "prompt": string } → 200 Continuation.
  • POST /api/continuations/:id/pause|resume|cancel → 200 Continuation. Resume rechecks conditions. Cancel retains the link and blocks downstream steps, including after restart; it does not splice the order.
  • Invalid input: 400. Missing resource: 404. Child/archived session, self-link, cycle, occupied live order, conflicting legacy task order or mutation after delivery claim: 409.

Release and delivery

The predecessor must be explicitly settled in T3. The target must have finished with no active run or pending input. Failure, interruption or cancellation blocks either endpoint even if its settled flag remains set. New work in the target keeps the continuation waiting. Recover in T3 before resuming. Existing backlog task links retain their documented behavior; unlink conflicting task order before creating a continuation.

For A → B → C, the second continuation references the first continuation. B's original run or old settlement cannot release C. The delivered run must finish successfully, followed by a settlement recorded after that delivery. Pausing and cancellation never stop the T3 session itself.

The send preserves the saved prompt verbatim and appends:



---
Prerequisite session
Title: <A title>
Thread ID: <A T3 thread ID>
T3: <canonical link, omitted when unavailable>

Canonical link order: configured T3 web URL, T3's returned link, app deep link. No transcript or final response is copied.

The runner refreshes all chain participants, including finished/settled sessions, then checks both endpoints before claiming delivery. A database claim persists the exact delivery prompt and unique request ID before t3_thread_send with mode: queue. Resulting message/run IDs track the continuation. Concurrent reconciles cannot claim the same continuation. There is no blind retry: a transport failure, missing receipt or expired claim becomes uncertain; review the target in T3. Resume cannot resend an uncertain/claimed continuation. If a delivered run falls outside T3's bounded recent-run history before completion was observed, block for review rather than infer success.

Session and continuation states are independent. Current runs may still edit concurrently; this orders future prompts. MCP does not expose every background wait, and readiness reads across sessions are not atomic. Queue mode prevents steering when the target starts again between read and send.