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/:idthreadandGET /api/tasks/:idthread; 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.textis 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
updatedAtchanged 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/projectTitlenull when no app project mirrorst3ProjectId.runStartedAt/runEndedAt: elapsed run span from the last detail read (t3_thread_readrecentRuns, up to 50): the oldest run'sstartedAt(requestedAtif it never started; threadcreatedAtwhenrunCountexceeds the runs read) to the newest run'scompletedAt; null without runs,runEndedAtnull 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_requestlist, counted whenever sync reads a thread with pending requests; a failed count keeps the previous value; reset to 0 when nothing is pending; never abovependingRequestCount).relationship:fork|subagent|null.creationSource: T3web|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_readlinkedPullRequestfirst, thenpullRequests, deduped byurl, entries without aurldropped).repositoryisowner/name;stateis T3's string (open,merged,closed,draft, ...);sourceis T3's (created,agent) orlinkedfor an unlabelledlinkedPullRequest. 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/:idthreadandGET /api/tasks/:idthread; always null in lists): newest first, at most 50 (T3runLimit), from the last detail read;[]before one.runCountcan 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):
| Param | Meaning |
|---|---|
project | app project id |
status | repeatable ThreadStatus |
state | repeatable normalized TaskState |
active | 1|true: unarchived, unsettled running/needs-input sessions across origins, no implicit date cutoff |
sort | activity: updatedAt desc; omitted: createdAt desc |
parent | direct children of this T3 thread ID |
settled | true|false |
provider | providerInstanceId |
source | app|t3 |
q | case-insensitive title substring |
from, to | createdAt |
subagents | include|exclude, default include; exclude = root threads only, parentThreadId null |
needsInput | 1|true: only external threads counted by the badge (see Notification rules); other filters still apply |
running | 1|true: only external running threads (see Notification rules); other filters still apply |
settledFrom | ISO date or datetime: only external settled threads with settledAt >= settledFrom, ordered by settledAt desc (see Notification rules); other filters still apply |
limit | default 100, max 500 |
offset | default 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:
- stage: root threads whose title contains
q(case-insensitive), newest activity first, up tolimit. - 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 tolimit.
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;truncatedtrue when the full text is longer (fetch it from the item endpoint).- Items are upserts keyed by
position: a newer copy (same or laterupdatedAt) 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 }.
itemsascending by position: the newestlimitmessages (belowbeforewhen given).hasEarlier: older messages exist (page withbefore= first item's position).cursor: resume point for the stream, null whenbeforeis 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.
| Event | data | Meaning |
|---|---|---|
item | TranscriptItem | new 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 trimmedlabelwhen it has no value) or custom text (not blank unlessinitialAnsweris set; only whenallowCustomAnsweris not false). - An array is for
multiSelectonly, 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:
| Status | code | When |
|---|---|---|
| 400 | invalid answers (message names the question) | |
| 404 | unknown thread | |
| 409 | already_answered | answered in T3, or by stage with different answers |
| 409 | request_stale | the request is gone or no longer pending (e.g. expired) |
| 502 | other T3 failure | |
| 503 | T3 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[] }. Optionalthread=<id>includes related orders, predecessor first, retaining history.POST /api/continuationsbody{ "afterThreadId": string, "targetThreadId": string, "prompt": string }→ 201Continuation. Required nonblank IDs/prompt. Prompt whitespace preserved. No send on save.PATCH /api/continuations/:idbody{ "prompt": string }→ 200Continuation.POST /api/continuations/:id/pause|resume|cancel→ 200Continuation. 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.