Quad API
Table of Contents
upsquad/quad/v1/quad.proto
GetSuggestionsRequest
GetSuggestionsRequest asks for a page of chips.
Tenant + user identity flow from gateway-injected JWT headers, not from the request body — same convention as ListThreads / PinThread / SendMessage.
| Field | Type | Label | Description |
|---|---|---|---|
| thread_id | string | thread_id is the Quad thread the chips are scoped to. May be empty — the V1 provider ignores it; the V2 LLM provider will use it to load recent thread events for personalisation. The empty value is a valid request (renders chips for the new- thread empty state). | |
| max_suggestions | int32 | max_suggestions caps the chip count. Server clamps to [1, 8]; 0 (or unset) defaults to 4. The cap matches the upper bound on the design's chip strip — anything larger overflows the viewport on mobile. |
GetSuggestionsResponse
GetSuggestionsResponse carries the chips in display order. An
empty suggestions slice is treated by the handler as a
provider error (CodeInternal) — "no chips" is a UI failure mode
worth flagging loudly rather than silently rendering an empty
strip — so callers can rely on len(suggestions) >= 1 on success.
| Field | Type | Label | Description |
|---|---|---|---|
| suggestions | Suggestion | repeated | suggestions is the chip list in display order. Length in [1, max_suggestions]. The provider chooses ordering; clients MUST NOT re-sort. |
ListThreadsRequest
ListThreadsRequest is the page-keyed list request. Tenant + user are sourced from JWT context; only paging knobs travel on the body.
| Field | Type | Label | Description |
|---|---|---|---|
| page_size | int32 | page_size caps the number of threads returned. Server clamps to [1, 100]; 0 (or unset) defaults to 20. The cap matches the upper bound on the redesigned client portal sidebar — anything larger is virtualised on the client and isn't worth the server-side round-trip. | |
| page_token | string | page_token is the opaque continuation token returned by the previous ListThreads call. Empty/unset starts at the first page. The server treats this as a black box; clients MUST NOT inspect or modify it. Token format may change without a proto bump. |
ListThreadsResponse
ListThreadsResponse carries one page of threads + the continuation token. An empty next_page_token signals last page.
| Field | Type | Label | Description |
|---|---|---|---|
| threads | Thread | repeated | threads is the page contents in sort order (pinned DESC, last_message_at DESC NULLS LAST, id DESC). |
| next_page_token | string | next_page_token is the opaque token to pass on the next call. Empty when no more pages exist. |
PinThreadRequest
PinThreadRequest sets or clears the pinned bit on a thread.
| Field | Type | Label | Description |
|---|---|---|---|
| thread_id | string | thread_id is the UUID of the thread to pin/unpin. Required. | |
| pinned | bool | pinned is the desired pinned state. The server is idempotent on both transitions — re-pinning an already-pinned thread keeps the existing pinned_at timestamp; un-pinning an already-unpinned thread is a no-op. |
PinThreadResponse
PinThreadResponse returns the post-mutation thread row so the client can reconcile its in-memory state without a re-list.
| Field | Type | Label | Description |
|---|---|---|---|
| thread | Thread | thread is the post-mutation thread row. |
SendMessageRequest
SendMessageRequest carries the user message for a chat thread. tenant_id / user_id are NOT carried on the request — they come from gateway-injected JWT headers, same convention as every other portal- facing Connect service in this repo (LifecycleService, ApprovalService).
| Field | Type | Label | Description |
|---|---|---|---|
| thread_id | string | thread_id is the Quad thread UUID. For now this maps 1:1 onto the underlying agent session_id. GAP-10 introduces a thread→session mapping table; until then callers SHOULD pass an existing session UUID here, and an empty thread_id creates a new session (downstream LifecycleService semantics). | |
| text | string | text is the user's input message. UTF-8, bounded at 32 KiB (enforced downstream by LifecycleService). | |
| target_agent_id | string | target_agent_id names the agent the message is directed at — the resolved target of a client-side @mention (M1, tracker #1536). |
The redesigned /quad page resolves an @mention to a concrete agent_configurations UUID client-side (deterministic, NOT LLM free-text parsing) and carries it here. The Quad proxy (internal/quad/connect_adapter.go) consumes this to select the ensure-session-for-(thread, agent) target and to drive the deterministic delegate_to_agent invocation — wired by T3/T4, not by this field's addition.
OPTIONAL and additive: an empty target_agent_id preserves the pre-M1 behaviour (message goes to the thread's default session / the user's own Quad orchestrator). Tenant + user identity still flow from gateway-injected JWT headers — never from this field. |
StreamTaskStatusRequest
StreamTaskStatusRequest names the Task to stream. Tenant + caller identity flow from JWT context; the caller may only stream a Task they requested.
| Field | Type | Label | Description |
|---|---|---|---|
| task_id | string | task_id is the canonical Task UUID returned by SubmitTask (required). |
SubmitTaskRequest
SubmitTaskRequest turns a Quad conversation into a Task. Tenant + caller identity flow from gateway-injected JWT context — never from this body — so there is deliberately no requester field: the Task is always created as the caller (see the SubmitTask RPC comment).
| Field | Type | Label | Description |
|---|---|---|---|
| thread_id | string | thread_id is the Quad conversation the ask originated from. It is the payload_ref of the created Task and is mixed into the idempotency key so two submissions of the same intent from the same thread collapse. May be empty for an ad-hoc submission with no thread context. | |
| title | string | title is the human-readable Task label (required). Typically the user's request summarised to <=120 chars by the client. | |
| intent | string | intent is an optional machine-readable category/verb for the request (e.g. "draft_email", "triage_incident"). It is recorded on the Task and mixed into the idempotency key. | |
| intent_tags | string | repeated | intent_tags are explicit deterministic routing hints for the MVP. They are forwarded to the Coordinator's TeamRouter as labels under two conventions: a "key:value" tag becomes the label {key: value} (so "team:<id>" / "domain:engineering" target the router directly), and bare tags — plus the intent, when set — are deduplicated, sorted, and comma-joined under the single "intent_tags" label for the capability matcher. They are also mixed into the idempotency key, order-insensitively (the key derivation sorts them so tag reordering does not defeat dedup). An LLM-assisted planner that infers these is a post-MVP follow-up. |
SubmitTaskResponse
SubmitTaskResponse returns the created Task's id + initial status. The client uses task_id to open StreamTaskStatus and to deep-link the Task card (F8).
| Field | Type | Label | Description |
|---|---|---|---|
| task_id | string | task_id is the canonical Task UUID created on behalf of the caller. | |
| status | upsquad.task.v1.TaskStatus | status is the Task's status at creation time (always TASK_STATUS_PENDING for a fresh submission; the prior status for a deduplicated re-submission). | |
| deduplicated | bool | deduplicated is true when this submission matched a prior submission's idempotency key and resolved to the existing Task instead of creating a new one (a double-tap). The client can use this to avoid showing a duplicate "task created" toast. |
Suggestion
Suggestion is one chip in the GetSuggestions response.
V1 mirrors the chips in the design mock at
docs/design/redesign-2026-04/project/quad.jsx SUGGESTIONS array
(4 chips: failing_now, most_approvals_week, over_budget_agents,
walk_through_approval). V2 (LLM provider) will personalise the
label/icon/prompt_text per user + per thread without changing the
wire shape.
| Field | Type | Label | Description |
|---|---|---|---|
| id | string | id is the opaque identifier surfaced to the client for telemetry and dedup. Stable for the life of one response; not stable across calls (V2 will mint per-response ids). | |
| label | string | label is the chip text shown in the UI (≤80 chars). | |
| icon | string | icon is a single-glyph hint (e.g. "⚠", "✓", "$", "→"). Empty when the provider has no icon preference. | |
| prompt_text | string | prompt_text is what the client should insert into the chat input on click. For V1 this equals label; the V2 LLM provider may diverge (e.g. label="Show failing agents" with prompt_text= "Show me a table of agents that have failed in the last 24h"). |
TaskStatusEvent
TaskStatusEvent is one Task status transition pushed to the caller. The first event on a stream reports the current status; subsequent events are emitted only when the status changes.
| Field | Type | Label | Description |
|---|---|---|---|
| task_id | string | task_id is the Task this event is about. | |
| status | upsquad.task.v1.TaskStatus | status is the Task's lifecycle state as of this event. | |
| routed_team_id | string | routed_team_id is the team the Task was routed to, once known. Empty until the Coordinator routes the Task. | |
| failure_reason | string | failure_reason explains a FAILED or REJECTED terminal state. Empty in all non-failure states. | |
| at | google.protobuf.Timestamp | at is when the Task last changed (the canonical Task's updated_at). |
Thread
Thread is the projection returned by ListThreads + PinThread. Mirrors the quad_threads table (migration 087) minus internal columns (org_id, user_id — both implicit from the auth context).
session_id is exposed so the client can correlate a thread with the underlying agent session if it ever wants to (e.g. for jumping into the workflow detail view). It is NOT load-bearing for the chat surface — a follow-up that decouples threads from sessions can drop or repurpose this field without breaking SendMessage.
| Field | Type | Label | Description |
|---|---|---|---|
| id | string | id is the thread UUID. Pass this as SendMessageRequest.thread_id and PinThreadRequest.thread_id. | |
| session_id | string | session_id is the underlying agent session UUID this thread wraps. Stable for the life of the thread. | |
| title | string | title is a human-readable thread label — typically the first user message excerpt (≤120 chars). May be empty for freshly-minted threads with no traffic yet. | |
| pinned | bool | pinned is true when the user has pinned this thread. | |
| pinned_at | google.protobuf.Timestamp | pinned_at is when the pin was last set; unset when pinned=false. | |
| last_message_at | google.protobuf.Timestamp | last_message_at is the timestamp of the most recent message on this thread. Unset when the thread has no message traffic yet. | |
| created_at | google.protobuf.Timestamp | created_at is when the thread row was minted. |
QuadService
QuadService exposes the chat surface for the portal /quad page.
Mounted on Connect-Web in cmd/context-engine/main.go next to
LifecycleService. The implementation is a thin gRPC-client proxy
into the authoritative LifecycleService running in agent-orchestrator.
| Method Name | Request Type | Response Type | Description |
|---|---|---|---|
| SendMessage | SendMessageRequest | .upsquad.runtime.v1.AgentEvent stream | SendMessage posts a user message to a chat thread and returns a stream of events (token, status, completion, error). Today this proxies into LifecycleService.SendMessage; later (GAP-12) the streamed event will become a Quad-native MessageEvent with a structured-answer variant. |
The skeleton mapping in the current proxy adapter is:
SendMessageRequest.thread_id -> runtime.v1.SendMessageRequest.session_id SendMessageRequest.text -> runtime.v1.SendMessageRequest.message
Empty thread_id requests a new session (mirrors LifecycleService semantics). All identity (tenant_id, user_id, clearance) flows from gateway-injected JWT headers — same convention as ApprovalService and LifecycleService on the ICS Connect mux. |
| ListThreads | ListThreadsRequest | ListThreadsResponse | ListThreads returns the calling user's chat threads under the resolved tenant, newest-active first with pinned threads pinned to the top. Tenant + user identity are sourced from gateway-injected JWT context — never from the request body.
Sort order (mirrors idx_quad_threads_list in migration 087):
ORDER BY pinned DESC, last_message_at DESC NULLS LAST, id DESC
pinned threads first (so the user's bookmarks stay at the top of the sidebar), then most-recently-active, with id as the keyset tiebreaker so pagination is stable when last_message_at ties.
Pagination is opaque keyset (NOT offset). Callers should treat next_page_token as a black box and pass it back unchanged. An empty next_page_token in the response means the last page has been served. |
| PinThread | PinThreadRequest | PinThreadResponse | PinThread sets or clears the pinned bit on a thread. Idempotent — re-pinning an already-pinned thread is a no-op (the pinned_at timestamp is preserved on idempotent re-pin so the UI's "pinned 2d ago" caption stays stable). Tenant + user identity are resolved from JWT context; the thread MUST belong to the calling user — RLS enforces tenant isolation, the service layer additionally enforces caller-is-owner. |
| GetSuggestions | GetSuggestionsRequest | GetSuggestionsResponse | GetSuggestions returns context-aware chips for the Quad chat input. The redesigned client portal /quad page shows these chips as one-tap prompts beneath the empty thread state and (forthcoming) inline within active threads.
V1 (this RPC, today): a deterministic StaticSuggestionProvider returns chips drawn from a small curated catalog mirroring the design mock. The same four chips are returned on every call. userID and threadID flow through but are ignored.
V2 (follow-up): an LLMSuggestionProvider personalises chips per user + per thread by reading recent thread events. The wire shape is unchanged — only the provider implementation swaps. Tests substitute a deterministic fake; the handler nil-checks the provider so older tests don't have to wire one (returns CodeUnimplemented when absent).
Tenant + user identity flow from gateway-injected JWT headers (x-tenant-id, x-user-id) — never from the request body. Same convention as ListThreads and PinThread. | | SubmitTask | SubmitTaskRequest | SubmitTaskResponse | SubmitTask turns a Quad conversation into a routed, executing Task (MVP work-routing spine, ADR-0015, issue #1230). It is the seam where Quad stops being a chat-only proxy: a user's ask becomes a Task.
Two side effects, in order:
-
Creates the Task via the canonical TaskService path (upsquad.task.v1.TaskService.CreateTask) ON BEHALF OF the caller — requester_member_id is forced to the caller's member identity (from the gateway-injected JWT context, NEVER the request body). The canonical handler's self-scoping rule applies unchanged: a member-scoped caller may only create their OWN task, so a clearance / permission mismatch surfaces as PERMISSION_DENIED from the Task handler — Quad does not re-implement that check, it relies on it.
-
Publishes a CONTROL(submit_task) envelope onto the tenant Coordinator queue (internal/bus). The envelope's task_id names the canonical Task created in step 1, and the Coordinator ADOPTS it (adopt-don't-create, ADR-0015): it attaches only its internal execution context and drives every status transition through the canonical Transitioner on that SAME row, so StreamTaskStatus observes the routed lifecycle. The Coordinator (B8) does the actual team routing + delegation; Quad stays thin and does NOT route. The envelope carries a deterministic idempotency_key derived from (org, thread_id, caller, intent-hash) so a double-tap from the same conversation does not double-create or double-route.
intent_tags are explicit deterministic routing hints for the MVP — the client sends them and they are forwarded to the Coordinator's TeamRouter as labels. An LLM-assisted planner that infers tags from the conversation is a post-MVP follow-up; this RPC is the stable seam for it.
Returns the canonical task_id (correlate StreamTaskStatus to it) and the initial status (PENDING). | | StreamTaskStatus | StreamTaskStatusRequest | TaskStatusEvent stream | StreamTaskStatus server-streams the lifecycle transitions of a Task to the caller until the Task reaches a terminal state (COMPLETED / FAILED / REJECTED / CANCELLED) or the caller disconnects.
The caller may only stream a Task they requested — the canonical TaskService self-scoping rule applies, so another member's task id reads as NOT_FOUND (its existence is itself not visible to a self-scoped caller).
The first event always reports the current status (so a late subscriber still learns where the Task is); subsequent events are emitted only on a status change. The stream mechanism is a bounded poll of the canonical Task store (the simplest correct mechanism available today) — a push-based status-event subscription is a post-MVP optimisation behind the same wire contract. |
Scalar Value Types
| .proto Type | Notes | C++ | Java | Python | Go | C# | PHP | Ruby |
|---|---|---|---|---|---|---|---|---|
| double | double | double | float | float64 | double | float | Float | |
| float | float | float | float | float32 | float | float | Float | |
| int32 | Uses variable-length encoding. Inefficient for encoding negative numbers – if your field is likely to have negative values, use sint32 instead. | int32 | int | int | int32 | int | integer | Bignum or Fixnum (as required) |
| int64 | Uses variable-length encoding. Inefficient for encoding negative numbers – if your field is likely to have negative values, use sint64 instead. | int64 | long | int/long | int64 | long | integer/string | Bignum |
| uint32 | Uses variable-length encoding. | uint32 | int | int/long | uint32 | uint | integer | Bignum or Fixnum (as required) |
| uint64 | Uses variable-length encoding. | uint64 | long | int/long | uint64 | ulong | integer/string | Bignum or Fixnum (as required) |
| sint32 | Uses variable-length encoding. Signed int value. These more efficiently encode negative numbers than regular int32s. | int32 | int | int | int32 | int | integer | Bignum or Fixnum (as required) |
| sint64 | Uses variable-length encoding. Signed int value. These more efficiently encode negative numbers than regular int64s. | int64 | long | int/long | int64 | long | integer/string | Bignum |
| fixed32 | Always four bytes. More efficient than uint32 if values are often greater than 2^28. | uint32 | int | int | uint32 | uint | integer | Bignum or Fixnum (as required) |
| fixed64 | Always eight bytes. More efficient than uint64 if values are often greater than 2^56. | uint64 | long | int/long | uint64 | ulong | integer/string | Bignum |
| sfixed32 | Always four bytes. | int32 | int | int | int32 | int | integer | Bignum or Fixnum (as required) |
| sfixed64 | Always eight bytes. | int64 | long | int/long | int64 | long | integer/string | Bignum |
| bool | bool | boolean | boolean | bool | bool | boolean | TrueClass/FalseClass | |
| string | A string must always contain UTF-8 encoded or 7-bit ASCII text. | string | String | str/unicode | string | string | string | String (UTF-8) |
| bytes | May contain any arbitrary sequence of bytes. | string | ByteString | str | []byte | ByteString | string | String (ASCII-8BIT) |