Skip to content

Coach API reference

Coach API reference

All routes are under /api/v0/coach/ and require session authentication (enforced by the outer authMiddleware on /api/v0). IDOR protection is enforced throughout: every resource lookup joins through created_by = authUserId or entity_id = entityId, so a caller can only read and modify their own resources.

Source: packages/api/src/routes/coach.ts.


Table of contents

  1. Conversations (new runtime)
  2. Planned actions
  3. Sessions (legacy runtime)
  4. Quests
  5. Goals
  6. Approvals
  7. Permissions
  8. Scheduled wake-ups
  9. User preferences
  10. Voice
  11. Backend routing
  12. SSE stream format

Conversations (new runtime)

The primary chat interface. Routes through getBackend().chat() — either LocalBackend (external meshi-agent-runtime) or AgentBackend (platform-native LLM loop), depending on MESHI_RUNTIME_BACKEND. Requires an existing entity; users who have not started onboarding receive 403.

POST /conversations

Create a new conversation.

Request body:

{ "title": "optional initial title" }

title is optional (string); auto-titled after the first turn if empty.

Response 200:

{
"conversation": {
"id": "uuid",
"created_by": "auth_user_id",
"title": "",
"visibility": "private",
"created_at": "...",
"updated_at": "..."
}
}

GET /conversations

List all conversations for the current user, newest first.

Response 200:

{ "conversations": [{ "id": "...", "title": "...", "created_at": "...", "updated_at": "..." }] }

GET /conversations/:id

Get a conversation and its full message history.

Response 200:

{
"conversation": { "id": "...", "title": "...", "created_at": "..." },
"messages": [
{
"id": "...",
"conversation_id": "...",
"role": "user | assistant",
"content": "...",
"parent_message_id": "uuid | null",
"source_type": "user | agent",
"response_object": null,
"created_at": "..."
}
]
}

response_object is non-null on assistant messages that were generated with tool calls — see SSE stream format for the tool_events shape.

Response 404: conversation not found or not owned by caller.


PATCH /conversations/:id

Update the conversation title.

Request body:

{ "title": "New title" }

title must be a string. Response 200: { "ok": true }.


PATCH /conversations/:cid/messages/:mid

Edit a message’s content. Used by the in-bubble edit affordance. Only messages in the caller’s own conversations are editable. Original content is stashed in message_metadata.edit_history for audit; edits do not branch the conversation (branching support is a future phase).

Request body:

{ "content": "corrected message text" }

Response 200: the updated message row. Response 404: conversation or message not found / not owned.


POST /conversations/:id/messages

Stream a chat turn. The primary message endpoint — this is what the web client calls for every user message.

Request body:

{ "content": "user message text (non-empty string, required)" }

Flow:

  1. Validates conversation ownership (404 if not found or not yours).
  2. Resolves entityId for the caller (403 if no entity — user must have started onboarding).
  3. Loads prior message history.
  4. Persists the user message immediately (survives mid-stream reloads).
  5. Opens an agent_run row with status='running'.
  6. Calls getBackend().chat() with the full message history and COACH_SYSTEM_PROMPT.
  7. Tees the upstream SSE stream: passes bytes straight through to the browser AND accumulates content + tool events server-side.
  8. On [DONE]: persists the assistant message (with tool_events in response_object), closes the agent_run row, and fire-and-forgets an auto-title call if the conversation is untitled.

Response: 200 text/event-stream (SSE). See SSE stream format.

Error responses:

StatusMeaning
400content is missing or empty
403No entity — complete onboarding first
404Conversation not found or not yours
502Backend returned an error; body contains upstream_status + upstream_body + user_message_id

Planned actions

Planned actions are agent-proposed steps enqueued during a coach turn. Users can approve, cancel, or snooze them from the chat UI.

PATCH /planned-actions/:id

Transition a planned action’s state.

Request body:

{ "op": "approve | cancel | snooze", "minutes": 30 }

minutes is required for snooze (positive integer, moves execute_after forward by that many minutes).

States and transitions:

Current stateapprovecancelsnooze
pendingapprovedcancelledpending (new execute_after)
approved409 alreadycancelledpending
executing409 in-flight409 in-flight409 in-flight
completed, cancelled, failed409 terminal409 terminal409 terminal

Authorization is enforced through the conversation: the planned action’s conversation_id must be owned by the caller.

Response 200: the updated planned action in the same shape that MCP tool results use:

{
"planned_action": {
"id": "...",
"conversation_id": "...",
"action_type": "...",
"intent": "...",
"reasoning": "...",
"state": "approved",
"priority": 0,
"execute_after": "...",
"parameters": {},
"user_explicitly_requested": true,
"created_at": "..."
}
}

Sessions (legacy runtime)

The legacy coach entry point — uses @meshi/coach directly, not getBackend(). Kept for backwards compatibility while the conversations endpoint is fully adopted.

POST /session

Start or continue a coach session.

Request body:

{
"message": "user message (required)",
"session_id": "optional existing session id",
"conversation_id": "optional conversation id",
"allow_onboarding": true
}

allow_onboarding: true enables conversational onboarding: if the user has no entity yet, startOnboarding(db, authUserId, email, {}) is called automatically (no LinkedIn URL), creating an entity in review/goals_review state. Without this flag, users without an entity receive 403.

Response: SSE stream from @meshi/coach.


GET /sessions

List recent sessions. Query param: limit (1–50, default 10).

Response 200: { "sessions": [...] }


GET /sessions/:id/actions

Get the why-path (action log) for a session.

Response 200: { "actions": [...] }. Returns 404 if the session belongs to another user.


Quests

Quests are structured tasks surfaced to the user by the platform or agent. The first quest created for every user is the “Meet your coach” welcome quest, generated inline after completeOnboarding().

GET /quests

List quests. Query params:

ParamTypeDefaultDescription
all"true"falseInclude completed + dismissed quests
limitnumber20Max results (1–50)

Response 200: { "quests": [...] }


POST /quests/:id/complete

Mark a quest as completed. Returns 404 if the quest is not found or not owned by the caller.

Response 200: the updated quest row.


POST /quests/:id/dismiss

Dismiss a quest (soft-remove from the active list). Returns 404 if not found or not owned.

Response 200: the updated quest row.


Goals

POST /goals/:id/confirm

Confirm a proposed goal, making it active for matchmaking. This is a user-only action; the agent does not confirm goals on the user’s behalf — it uses create_goal and then surfaces the proposed goal for the user to confirm explicitly.

Response 200: the updated goal. Response 400: error message from the service layer. Response 404: goal not found or not owned.


Approvals

The approval queue holds agent-proposed actions that require explicit user consent before execution.

GET /approvals

List pending approvals for the current user.

Response 200: { "approvals": [...] }


POST /approvals/:id/decide

Decide on a pending approval.

Request body:

{
"decision": "approved | rejected | snoozed",
"note": "optional note (max 1000 chars)"
}

When decision = "approved", fires a coach/approval.decided Inngest event to trigger execution (fire-and-forget — the approval is recorded even if the event emit fails). Returns 404 if the item is not found or already decided.

Response 200: the updated approval item.


Permissions

Permission grants control what surfaces and scopes the agent is allowed to act on autonomously without per-action approval.

Valid surfaces: coaching, network, email, nudge
Valid scopes: read, write, full, coaching

GET /permissions

List active permission grants for the current user.

Response 200: { "permissions": [...] }


POST /permissions

Grant a permission.

Request body:

{ "surface": "network", "scope": "read" }

Both fields are required strings. Returns 400 if surface or scope is not in the allowed set.

Response 201: the created grant.


DELETE /permissions/:surface/:scope

Revoke a permission grant. Returns 404 if not found or already revoked.

Response 200: { "revoked": true }


Scheduled wake-ups

GET /scheduled

List pending scheduled wake-ups for the current user.

Response 200: { "scheduled": [...] }


User preferences

GET /preferences

Get the current user’s coach preferences.

Response 200:

{
"preferences": {
"timezone": "America/Los_Angeles",
"quiet_hours_start": 22,
"quiet_hours_end": 8,
"notification_email": true
}
}

POST /preferences

Update coach preferences. All fields are optional — pass only what you want to change.

Request body:

{
"timezone": "America/Los_Angeles",
"quiet_hours_start": 22,
"quiet_hours_end": 8,
"notification_email": true
}
FieldTypeValidation
timezonestringMust be a valid IANA timezone identifier (validated via Intl.DateTimeFormat)
quiet_hours_startnumber0–23
quiet_hours_endnumber0–23
notification_emailboolean

Response 200: { "preferences": { ... } }


Voice

GET /voice

WebSocket endpoint for voice sessions. Requires a WebSocket upgrade (Upgrade: websocket header). Returns 426 if the header is missing.

Delegates to handleVoiceWebSocket() from @meshi/coach. The WebSocket receives the same userId / entityId context as the REST endpoints.


Backend routing

The POST /conversations/:id/messages endpoint calls getBackend() which returns one of:

MESHI_RUNTIME_BACKENDBackendNotes
local (default)LocalBackendHTTP roundtrip to meshi-agent-runtime via Fly internal DNS. Requires MESHI_RUNTIME_URL + MESHI_RUNTIME_API_KEY.
agentAgentBackendIn-process LLM loop; 20 tools including onboarding. Requires AGENT_API_KEY (or CEREBRAS_API_KEY / OPENROUTER_API_KEY).
flyFlyBackendDecommissioned. Do not use.

See agent-runtime-integration.md for the full backend mode matrix.


SSE stream format

The POST /conversations/:id/messages response is text/event-stream. The stream carries several event types:

meshi-meta events

Emitted twice: once at stream start (before any content), once at stream end.

event: meshi-meta
data: {"user_message_id": "uuid", "conversation_id": "uuid"}
event: meshi-meta
data: {"assistant_message_id": "uuid"}

The first meshi-meta lets the client swap its optimistic user message bubble for the real persisted ID. The second lets the client record the assistant message ID once it is persisted.

Content delta events (OpenAI shape)

Standard OpenAI streaming format, passed through verbatim from the backend:

data: {"choices":[{"delta":{"content":"Hello"}}]}
data: {"choices":[{"delta":{"content":", world"}}]}
data: [DONE]

Tool events (runtime-native, passed through)

When the backend is meshi-agent-runtime (local mode), the runtime may emit custom SSE events for tool-call chrome. These are passed through to the browser verbatim:

event: meshi-tool-result
data: {...}
event: meshi-pre-tool-reasoning
data: {...}

Persisted response_object.tool_events

The platform accumulates tool-call events from the stream and stores them on the assistant message’s response_object column so reloads can rebuild the tool-call chrome without replaying the runtime.

type ToolEvent =
| { type: "call"; index: number; id?: string; name?: string; arguments_json: string }
| { type: "result"; tool_call_id: string; content: string }
| { type: "pre_reasoning"; content: string };

The array is chronological. A typical multi-round turn: [pre_reasoning, call, result, pre_reasoning, call, result] then final prose in message.content.

Present only on role='assistant' messages. Pre-migration messages (and messages from turns with no tool calls) have response_object = null — consumers must handle both.

agent_run observability

Every turn opens an agent_run row (migration 083) before calling the backend and closes it on stream completion:

ColumnDescription
conversation_idThe conversation this turn belongs to
message_idBackfilled with the assistant message id on success
modelModel name as reported by the runtime (may be null if backend doesn’t emit it)
tool_call_countNumber of distinct tool calls observed across all rounds
statusrunning on open → succeeded / failed / aborted on close (timed_out is in the schema but never set by this handler)
error_messageSet on failed — includes "no content received from runtime" when the upstream closed cleanly with no text
finished_atTimestamp when stream closed

Empty-response path: If the upstream SSE closes cleanly but no content delta was ever received (assistantContent.length === 0), no assistant message row is created, agent_run.message_id stays null, and status is set to failed with error_message = "no content received from runtime". The second meshi-meta event (assistant_message_id) is not emitted in this path. Clients must handle the absence of the closing meshi-meta event.

Abandoned streams (client disconnect before content arrives) leave rows in running state — these can be identified by finished_at IS NULL after a reasonable timeout.