Mobile Companion API v1 (Contract Freeze)
This document defines the API contract for the Loom Companion iPhone/iPad app.
Status: v1 additive contract freeze (updated 2026-04-16). Initial M0/M1 endpoints plus read-only parity wave endpoints are implemented under internal/hud/domain/mobile/.
Tracking
- MBL-1: Auth bootstrap decision gate (M0)
- MBL-2: Token lifecycle hardening (M1)
- MBL-3: Mobile policy and mutation guardrails (M1/M3)
- MBL-4: LAN permission diagnostics and profile health (M2)
- MBL-5: SSE resilience and fallback SLOs (M2/M5)
- MBL-6: Notification severity and action policy (M4)
- MBL-7: Push reliability and throttling controls (M4/M5)
- MBL-8: Scope discipline enforcement (cross-cutting)
- MBL-9: Gateway TLS validation enforcement (M1)
- MBL-10: Rate limiting for mobile mutation endpoints (M1)
- MBL-11: Mobile credential revocation steps in incident runbook
Goals
- Provide a stable mobile-facing contract without coupling the app to internal HUD handler shapes.
- Support both connectivity modes:
- LAN mode (trusted/local network)
- Gateway mode (remote/zero-trust)
- Keep v1 scope focused on monitoring + session lifecycle control.
- Preserve read-only posture for parity-wave features (tasks/workflows/presence/memory/stream/topology/graph/reasoning).
Versioning
- API prefix:
/api/mobile/v1 - Compatibility target: additive-only changes within
v1when possible. - Breaking changes require
v2or explicit migration notes.
Connectivity Modes
The same API contract is used in both modes.
| Mode | Typical endpoint | Primary use case |
|---|---|---|
| LAN | https://<lan-host>:<port>/api/mobile/v1 | Same network, low-latency ops |
| Gateway | https://mcp.flexinfer.ai/api/mobile/v1 | Off-network remote operations |
Notes:
- Client profile selects mode.
- Gateway mode must not assume LAN trust.
- Unified gateway path split on
mcp.flexinfer.ai:- MCP hub:
/ws,/hosts,/health,/ready - Mobile API:
/api/mobile/v1/*
- MCP hub:
Auth Model (Contract-Level)
Authorization: Bearer <token>required for protected endpoints.- See Mobile Companion Auth Bootstrap for the full auth bootstrap decision, flow diagrams, and LAN/gateway comparison.
- Bootstrap decision (MBL-1):
- v1 default: direct native OAuth authorization code + PKCE in an external browser/system auth session.
- v1 fallback: device-code pairing for profiles where direct browser-mediated auth is not practical.
- Fallback path is explicit and profile/policy selected; do not silently downgrade from OAuth+PKCE.
- Client UX contract: connection profile setup must display the active bootstrap mode and clearly indicate when fallback mode is being used.
- Token claims must include:
- actor identity (
subor equivalent), - role/scope,
- device/session identifier.
- actor identity (
- Anonymous access is limited to optional health/probe routes only.
mobile_operator Authorization Matrix (Contract View)
This section freezes the v1 endpoint allowlist for the mobile_operator role. Routes are classified as core_operator, advanced_read, or guarded_mutation; any mutation requires its named scope and remains denied by default without it.
| Endpoint | Method | Category | Access | Scope |
|---|---|---|---|---|
/api/mobile/v1/ping | GET | core_operator | allow | mobile:read |
/api/mobile/v1/dashboard | GET | core_operator | allow | mobile:read |
/api/mobile/v1/control-plane | GET | core_operator | allow | mobile:read |
/api/mobile/v1/sessions | GET | core_operator | allow | mobile:read |
/api/mobile/v1/sessions/tree | GET | core_operator | allow | mobile:read |
/api/mobile/v1/sessions/{session_id} | GET | core_operator | allow | mobile:read |
/api/mobile/v1/sessions/{session_id}/events | GET | advanced_read | allow | mobile:read |
/api/mobile/v1/sessions/{session_id}/trace | GET | advanced_read | allow | mobile:read |
/api/mobile/v1/sessions/{session_id}/activity | GET | core_operator | allow | mobile:read |
/api/mobile/v1/tasks | GET | core_operator | allow | mobile:read |
/api/mobile/v1/workflows | GET | core_operator | allow | mobile:read |
/api/mobile/v1/workflows/{workflow_id} | GET | core_operator | allow | mobile:read |
/api/mobile/v1/presence | GET | core_operator | allow | mobile:read |
/api/mobile/v1/agents | GET | core_operator | allow | mobile:read |
/api/mobile/v1/namespaces | GET | core_operator | allow | mobile:read |
/api/mobile/v1/memory/stats | GET | advanced_read | allow | mobile:read |
/api/mobile/v1/memory/items | GET | advanced_read | allow | mobile:read |
/api/mobile/v1/stream | GET | advanced_read | allow | mobile:read |
/api/mobile/v1/topology | GET | advanced_read | allow | mobile:read |
/api/mobile/v1/graph/stats | GET | advanced_read | allow | mobile:read |
/api/mobile/v1/graph/entities | GET | advanced_read | allow | mobile:read |
/api/mobile/v1/graph/path | GET | advanced_read | allow | mobile:read |
/api/mobile/v1/reasoning/chains | GET | advanced_read | allow | mobile:read |
/api/mobile/v1/reasoning/chains/{chain_id} | GET | advanced_read | allow | mobile:read |
/api/mobile/v1/events/stream | GET | advanced_read | allow | mobile:read |
/api/mobile/v1/audit | GET | advanced_read | allow | mobile:read |
/api/mobile/v1/alerts/policy | GET | core_operator | allow | mobile:read |
/api/mobile/v1/sandbox | GET | core_operator | allow | mobile:read |
/api/mobile/v1/telemetry/recovery | GET | core_operator | allow | mobile:read |
/api/mobile/v1/pipelines | GET | core_operator | allow | mobile:read |
/api/mobile/v1/handoffs | GET | core_operator | allow | mobile:read |
/api/mobile/v1/handoffs/{handoff_id}/accept | POST | guarded_mutation | allow | mobile:read |
/api/mobile/v1/handoffs/{handoff_id}/reject | POST | guarded_mutation | allow | mobile:read |
/api/mobile/v1/agent/spawns | GET | core_operator | allow | mobile:read |
/api/mobile/v1/agent/spawn/config | GET | core_operator | allow | mobile:read |
/api/mobile/v1/agent/spawn/{spawn_id} | GET | core_operator | allow | mobile:read |
/api/mobile/v1/agent/spawn/{spawn_id}/stream | GET | advanced_read | allow | mobile:read |
/api/mobile/v1/agent/spawn/{spawn_id}/telemetry | GET | advanced_read | allow | mobile:read |
/api/mobile/v1/agent/spawn/{spawn_id}/telemetry/tools | GET | advanced_read | allow | mobile:read |
/api/mobile/v1/agent/spawn/{spawn_id}/telemetry/files | GET | advanced_read | allow | mobile:read |
/api/mobile/v1/agent/spawn/{spawn_id}/telemetry/errors | GET | advanced_read | allow | mobile:read |
/api/mobile/v1/agent/spawn/{spawn_id}/trace | GET | advanced_read | allow | mobile:read |
/api/mobile/v1/sessions | POST | guarded_mutation | allow | mobile:session:create |
/api/mobile/v1/sessions/{session_id}/end | POST | guarded_mutation | allow | mobile:session:end |
/api/mobile/v1/push/register | POST | guarded_mutation | allow (feature-flagged) | mobile:push |
/api/mobile/v1/push/unregister | POST | guarded_mutation | allow (feature-flagged) | mobile:push |
/api/mobile/v1/telemetry/recovery | POST | guarded_mutation | allow (scope-gated, off by default) | mobile:telemetry |
/api/mobile/v1/admin/revoke | POST | guarded_mutation | allow (admin-gated) | admin token + mobile:read |
/api/mobile/v1/sandbox/start | POST | guarded_mutation | allow | mobile:agent:spawn |
/api/mobile/v1/sandbox/stop | POST | guarded_mutation | allow | mobile:agent:spawn |
/api/mobile/v1/workflows/{workflow_id}/approve | POST | guarded_mutation | allow | mobile:agent:spawn |
/api/mobile/v1/workflows/{workflow_id}/reject | POST | guarded_mutation | allow | mobile:agent:spawn |
/api/mobile/v1/agent/spawn | POST | guarded_mutation | allow | mobile:agent:spawn |
/api/mobile/v1/agent/spawn/{spawn_id}/stop | POST | guarded_mutation | allow | mobile:agent:spawn |
/api/mobile/v1/agent/spawn/{spawn_id}/message | POST | guarded_mutation | allow | mobile:agent:spawn |
/api/mobile/v1/agent/spawn/{spawn_id}/interrupt | POST | guarded_mutation | allow | mobile:agent:spawn |
/api/mobile/v1/agents/{agent_id}/session/end | POST | N/A | deny in v1 | N/A |
/api/agent/* direct mutation routes | POST | N/A | deny for mobile tokens | N/A |
Mode policy:
- LAN and gateway use the same endpoint permissions.
- Gateway requires TLS and strict cert validation.
- Deny by default when required scope is missing.
- The matrix above covers
/api/mobile/v1. Amobile_operatortoken is also allowed to reach a small set of HUD read/board paths outside that prefix — see Companion allowlist.
Response Contract
For mobile endpoints, use a consistent envelope:
{
"ok": true,
"data": {},
"meta": {
"request_id": "req_...",
"timestamp": "2026-02-19T19:00:00Z"
}
}
Errors:
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "invalid token"
},
"meta": {
"request_id": "req_...",
"timestamp": "2026-02-19T19:00:00Z"
}
}
Endpoints (v1 Frozen)
All endpoints return the standard envelope. The data field for each is defined below.
Wave 1 Read-Only Parity Additions (2026-02-25)
The following additive endpoints are now part of /api/mobile/v1:
| Endpoint | Purpose | Query Params | Response Shape |
|---|---|---|---|
GET /tasks | Task list + status counts | status, agent_id, session_id, limit, search | {tasks, counts} |
GET /workflows | Workflow summaries | status, agent_id, limit | {workflows, pending_approvals} |
GET /workflows/{workflow_id} | Workflow detail + timeline events | none | {workflow, events} |
GET /presence | Presence + claim/worktree snapshot | status, agent_id, limit | {agents, claims, worktrees, summary} |
GET /memory/stats | Memory tier totals | none | {stats} |
GET /memory/items | Memory recall (read-only) | tier, query, limit | {items, tier} |
GET /stream | Context stream entries | types, agent_id, session_id, limit | {entries} |
GET /topology | Agent topology graph | none | {nodes, edges, clusters, updated_at} |
GET /graph/stats | Graph counts by type | none | {stats} |
GET /graph/entities | Entity search/list | type, q, limit | {entities} |
GET /graph/path | Path between two entities | source_id, target_id, max_depth | {path} |
GET /reasoning/chains | Reasoning chain summaries | status, limit | {chains} |
GET /reasoning/chains/{chain_id} | Reasoning chain detail | none | {chain} |
GET /sessions/tree | Server-computed session hierarchy | status | {roots, orphans, summary} |
GET /sessions/{session_id}/activity | Task/pipeline pressure for a session | none | {session_id, tasks, pipelines, task_count, pipeline_count} |
GET /agents | Unified agent roster | status, type, limit | {agents, summary} |
GET /namespaces | Namespace rollup | none | {namespaces} |
GET /pipelines | Active and recent pipelines | none | {pipelines, recent_pipelines, summary, available} |
GET /handoffs | Handoff inbox | limit | {handoffs, total} |
POST /handoffs/{handoff_id}/accept | Accept a pending handoff (body: session_id or target_agent_id, optional import_entries) | none | {status:"accepted", handoff_id, session_id, result} |
POST /handoffs/{handoff_id}/reject | Reject a pending handoff (body: optional reason) | none | {status:"rejected", handoff_id, result} |
GET /agent/spawns | Spawn roster | none | {spawns} |
GET /agent/spawn/config | Spawn project/type config | none | {projects, agent_types} |
GET /agent/spawn/{spawn_id} | Spawn detail | none | spawn status object |
GET /agent/spawn/{spawn_id}/stream | Spawn event stream | none | SSE |
GET /agent/spawn/{spawn_id}/telemetry | Spawn telemetry summary | none | telemetry object |
GET /agent/spawn/{spawn_id}/telemetry/tools | Spawn tool-call telemetry page | offset, limit | page object |
GET /agent/spawn/{spawn_id}/telemetry/files | Spawn file telemetry page | offset, limit | page object |
GET /agent/spawn/{spawn_id}/telemetry/errors | Spawn error telemetry page | offset, limit | page object |
GET /agent/spawn/{spawn_id}/trace | Spawn trace | none | trace object |
Contract rules for these additions:
- Additive-only fields (no breaking shape changes under
v1). - Explicit array defaults (
[]instead ofnull). - Status fields normalize unknown values to
"unknown"where applicable. - All endpoints remain scope-gated by
mobile:read. They are not the only paths a mobile token may reach — see Companion allowlist (non-/api/mobile/v1paths).
Alerting and auto-fix routes (registered, not in the matrix above)
These live in internal/hud/domain/alerting/alerting.go (see its route table)
rather than the mobile domain, so they are easy to miss. They are real,
registered /api/mobile/v1 routes:
| Endpoint | Purpose | Response shape | Admin-gated | iOS caller |
|---|---|---|---|---|
GET /alerts | Alert list for the companion | {"alerts": [...]} | no | AlertsViewModel.loadAlerts() |
POST /alerts/{id}/ack | Acknowledge one alert | {"acked": true, "id": "..."} | no | AlertsViewModel.ack(_:) |
GET /autofix/proposals | Auto-fix proposals awaiting a decision | {"proposals": [...]} | no | AlertsViewModel.loadProposals() |
POST /autofix/proposals/{id}/approve | Approve and immediately execute a proposal | 202 + {"execution": {...}} | yes | AlertsViewModel.approveProposal(_:) |
POST /autofix/proposals/{id}/reject | Reject a proposal | {"rejected": true, "proposal_id": "..."} | no | AlertsViewModel.rejectProposal(_:) |
These five responses are bare JSON, not the APIEnvelope. The alerting
domain writes with the HUD's plain writeJSON (internal/hud/app.go), so
clients must use the raw decode path — in the iOS app,
LoomAPIClientProtocol.requestRaw, the same path the /api/mills/* and
/api/weaver/* proxy reads use. Errors arrive as {"error": "..."} and fall
through to HTTP-status mapping. Query parameters on GET /alerts: limit
(default 50) and severity (exact match, no filter when empty).
Semantics worth knowing before building UI on top of these:
- The alert store is an in-memory ring buffer capped at 200 entries
(
maxAlertHistoryininternal/hud/alerting/engine.go), newest first. It gives a client history across app launches but resets when the HUD restarts. It is not an audit log. - Ack does not remove an alert.
AckAlertstampsacked_at/acked_byon the record in place, so an acked alert keeps appearing inGET /alerts. There is no delete route. Request body is{"acked_by": "..."}; a missing or empty value defaults to"hud-user"(the app sends"ios-companion"). - Approve executes immediately.
handleApproveProposallooks the proposal up and callsExecuteAutoFixsynchronously; there is no separate "run" step. What that does depends onstrategy:agent_fix— spawns a headlessclaude-codeagent. With no spawn orchestrator wired the execution comes backfailed/"no spawn orchestrator available".retry— a no-op placeholder. The execution is markedsucceededwith result"pipeline retry requested"but no pipeline is re-run; the GitLab re-run is still a TODO inExecuteAutoFix. UI copy must not claim otherwise.- anything else — completes
failed/"manual intervention required".
- Neither approve nor reject mutates the proposal list. Both only append an
execution record, so
GET /autofix/proposalskeeps returning decided proposals. Clients must track their own decisions (the iOS app keepsdecidedProposalIDsfor the session). - Approve is the one admin-gated
/api/mobile/v1route. It callsRequireAdminToken, so a pairing-only device gets a 401.Endpointcorrespondingly listsautofixApproveinrequiresAdminToken, andAPIClientattachesX-Admin-Tokenfor it.alertAckandautofixRejectare not gated.
App status: the iOS companion consumes all five. AlertsViewModel merges the
server store (history, source of truth for acked_at) with the SSE stream
(liveness) into one list keyed by alert id; a pipeline.alert SSE event carries
the store's own alerting.Alert record, so live and stored copies dedupe rather
than double-list. There is one read state: for server-backed alerts,
AlertItem.isRead is the server ack, so marking read POSTs /alerts/{id}/ack
and rolls back on failure. The inbox is presented as a sheet from the Dashboard
(AlertsListView) — there is no Alerts tab; Spawn took that slot — and is
reached from the INBOX header, the critical-alert hero card, the alert
attention lane, and the loom://alerts / loom://alert/<id> deep links.
Companion allowlist (non-/api/mobile/v1 paths)
A mobile_operator token is not restricted to /api/mobile/v1/*. The HUD
also lets it reach the read/board surfaces the companion renders directly off
the HUD proxy. Source of truth: mobileCompanionExtraPrefixes /
mobileCompanionExactPaths in internal/hud/api_mobile.go.
| Path | Match | Used by |
|---|---|---|
/api/mobile/v1/ | prefix | the whole mobile surface |
/api/mills/ | prefix | Mills screen (pipeline runs, KPIs, backlog, spinning room, spin runs, escalate) |
/api/plans/ | prefix | Plan detail, advance, priority |
/api/weaver/ | prefix | Weaver screen (status, history, metrics) |
/api/aimodels/ | prefix | Weaver role defaults |
/api/plans | exact | Plan board collection |
/api/patterns | exact | Shift report's Pattern Loom catalog (?status=approved) |
Notes:
/api/patternsis deliberately an exact path, not a prefix, soPOST /api/patterns/stampstays out of reach of a mobile token.- Reaching a route is not the same as being allowed to mutate it. Mills
mutations (async spin, pipeline escalate) stay behind the HUD admin token
(
X-Admin-Token/HUD_ADMIN_TOKEN,requireAdminToken); the app sends the stored admin token only on those two endpoints (Endpoint.requiresAdminToken). Plan advance/priority are intentionally open, matching the HUD web frontend. - Everything else —
/api/agent/*, labs, spawn admin, and the rest of the HUD admin surface — remains denied for mobile tokens.
These surfaces return BARE JSON, not the mobile envelope
/api/mills/*, /api/plans*, /api/weaver/*, /api/aimodels/* and
/api/patterns are served by the HUD's plain writeJSON. They do not carry
the {ok, data, meta} envelope described under
Response Contract. Two consequences for clients:
- They must be decoded raw. In the iOS app that is
LoomAPIClientProtocol.requestRaw; routing them through the envelopedrequest(_:)fails decoding on every response (this surfaced once as a permanent "Couldn't reach Mills"). - Empty collections arrive as
null, not[]— the[]-instead-of-nullrule above applies to/api/mobile/v1only. Decode these as optional arrays and coalesce (?? []).
Error bodies from these paths are bare too, so operator-absence and
operator-unreachability reach the client only as HTTP status codes (503 when
LOOM_MILLS_OPERATOR_URL is unset, 502 when the operator is down), never as an
enveloped not_configured error code. Clients should treat 404/502/503 — and
403, for daemons whose allowlist predates a given path — as a calm empty state
rather than a hard error.
GET /api/mobile/v1/ping
Connectivity probe. Scope: mobile:read.
Response data:
{
"pong": true
}
Source: internal/hud/domain/mobile/handler_dashboard.go
GET /api/mobile/v1/dashboard
Mobile dashboard aggregate for quick app open. Scope: mobile:read.
Attention lanes preserve their original v1 fields and may include additive routing metadata so clients can dispatch directly to the right surface:
{
"type": "blocked_task",
"id": "task-123",
"label": "Blocked task",
"route": "work",
"scope": "loom-core/mobile",
"summary": "2 blocked tasks",
"severity": "warning",
"target_kind": "task_filter",
"target_id": "",
"deep_link": "loom://tasks?status=blocked&session=sess_abc123",
"filter": {
"status": "blocked",
"session_id": "sess_abc123"
},
"recommended_action": "Review blocked tasks",
"freshness": {
"source": "fleet_snapshot",
"updated_at": "2026-05-11T14:00:00Z"
}
}
Supported target_kind values are agent, session, task_filter,
workflow, spawn, handoff, connection, alert, pipeline, and
namespace. If these fields are absent, clients must fall back to route.
Response data:
{
"daemon_running": true,
"server_count": 5,
"active_sessions": 2,
"active_agents": 3,
"idle_agents": 1,
"offline_agents": 0,
"updated_at": "2026-02-23T12:00:00Z",
"health": {
"total_servers": 5,
"healthy_servers": 4,
"degraded_servers": 1,
"down_servers": 0,
"idle_servers": 0
},
"recent_timeline": [
{
"timestamp": "2026-02-23T11:59:00Z",
"event_type": "agent.session.start",
"agent_id": "claude-code",
"agent_type": "claude-code",
"data": {}
}
]
}
| Field | Type | Description |
|---|---|---|
daemon_running | bool | Whether loomd is running |
server_count | int | Total registered MCP servers |
active_sessions | int | Sessions with status active |
active_agents | int | Agents with presence status active |
idle_agents | int | Agents with presence status idle |
offline_agents | int | Agents with presence status offline |
updated_at | string (RFC3339) | Fleet snapshot timestamp |
health | object | Server health summary (see HealthSummary) |
health.total_servers | int | Total servers monitored |
health.healthy_servers | int | Servers passing health checks |
health.degraded_servers | int | Servers with intermittent failures |
health.down_servers | int | Servers failing health checks |
health.idle_servers | int | Servers with no recent activity |
recent_timeline | array | Last 10 TimelineEntry objects |
TimelineEntry schema:
| Field | Type | Description |
|---|---|---|
timestamp | string (RFC3339) | Event time |
event_type | string | Event type identifier |
agent_id | string | Agent that generated the event (omitted if N/A) |
agent_type | string | Agent type (omitted if N/A) |
data | object | Event-specific payload |
Source: internal/hud/domain/mobile/handler_dashboard.go, internal/hud/monitor/fleet.go, internal/hud/monitor/health.go, internal/hud/eventlog.go
GET /api/mobile/v1/sessions
List all sessions. Scope: mobile:read.
Response data:
{
"sessions": [
{
"id": "sess_abc123",
"agent_id": "claude-code",
"namespace": "loom-core/main",
"status": "active",
"description": "Working on mobile API",
"started_at": "2026-02-23T10:00:00Z",
"entry_count": 42,
"total_tokens": 8500
}
]
}
SessionInfo schema:
| Field | Type | Description |
|---|---|---|
id | string | Session identifier |
agent_id | string | Owning agent |
namespace | string | Session namespace |
status | string | active or ended |
description | string | Human-readable session description |
started_at | string (RFC3339) | Session start time |
ended_at | string (RFC3339) | Session end time (omitted if active) |
entry_count | int | Number of context entries |
total_tokens | int | Estimated token usage |
parent_session_id | string | Direct parent session when this row is child/subagent work |
root_session_id | string | Root session for the hierarchy; roots normalize to their own id |
Source: internal/hud/domain/mobile/handler_sessions.go, internal/hud/bridge/agent.go
GET /api/mobile/v1/sessions/{session_id}
Single session detail. Scope: mobile:read.
Response data:
{
"session": {
"id": "sess_abc123",
"agent_id": "claude-code",
"namespace": "loom-core/main",
"status": "active",
"description": "Working on mobile API",
"started_at": "2026-02-23T10:00:00Z",
"entry_count": 42,
"total_tokens": 8500
}
}
Returns a single SessionInfo (same schema as above) under data.session.
Error cases:
400 bad_request— missingsession_id404 not_found— session not found
Source: internal/hud/domain/mobile/handler_sessions.go
GET /api/mobile/v1/sessions/tree
Server-computed session hierarchy. Scope: mobile:read.
Query:
status: omitted defaults to active sessions;allreturns all known statuses; comma-separated values filter by status.
Response data:
{
"roots": [
{
"session": {},
"depth": 0,
"child_count": 2,
"active_child_count": 1,
"children": []
}
],
"orphans": [],
"summary": {
"root_count": 3,
"active_sessions": 5,
"orphan_sessions": 1,
"updated_at": "2026-05-11T14:00:00Z"
}
}
Root sessions with no explicit root_session_id normalize their root to their
own id. Sessions whose parent_session_id is missing from the result are
returned under orphans so clients can render them distinctly.
Source: internal/hud/domain/mobile/handler_sessions.go
GET /api/mobile/v1/sessions/{session_id}/events
Session-scoped event feed. Scope: mobile:read.
Query parameters:
| Param | Type | Default | Max | Description |
|---|---|---|---|---|
limit | int | 100 | 500 | Maximum events to return |
Response data:
{
"session_id": "sess_abc123",
"events": [
{
"timestamp": "2026-02-23T11:00:00Z",
"event_type": "agent.session.start",
"agent_id": "claude-code",
"agent_type": "claude-code",
"data": {"session_id": "sess_abc123"}
}
]
}
| Field | Type | Description |
|---|---|---|
session_id | string | Echo of requested session ID |
events | array | TimelineEntry objects matching this session |
Error cases:
400 bad_request— missingsession_id
Source: internal/hud/domain/mobile/handler_sessions.go
GET /api/mobile/v1/sessions/{session_id}/trace
Unified session trace. Scope: mobile:read.
This endpoint mirrors the desktop HUD session trace drawer for companion clients. It composes cached session metadata, session-scoped lifecycle events, context entries, and daemon audit/tool traces. If one source fails, for example because agent-context returns transport closed, the response still returns 200 with the remaining sources and a source-scoped item in data.errors.
Query parameters:
| Param | Type | Default | Max | Description |
|---|---|---|---|---|
limit | int | 100 | 500 | Maximum entries/events/traces per source |
agent_id | string | - | - | Optional fallback filter when session metadata is unavailable |
Response data:
{
"session_id": "sess_abc123",
"agent_id": "claude-code",
"session": {"id": "sess_abc123", "agent_id": "claude-code", "status": "active"},
"entries": [],
"events": [],
"traces": [
{
"timestamp": "2026-04-16T12:00:00Z",
"agent_id": "claude-code",
"server": "agent_context",
"tool": "agent_context_search",
"status": "error",
"error": "transport closed",
"duration_ms": 12
}
],
"trace_enabled": true,
"trace_path": "/tmp/loom-audit.jsonl",
"errors": [
{"source": "context_entries", "message": "transport closed"}
],
"retrieved_at": "2026-04-16T12:00:01Z"
}
Source: internal/hud/domain/mobile/handler_sessions.go, internal/hud/app_routes_operations.go
GET /api/mobile/v1/sessions/{session_id}/activity
Session task and pipeline pressure summary. Scope: mobile:read.
Response data:
{
"session_id": "sess_abc123",
"tasks": [
{
"id": "task-1",
"title": "Fix mobile route",
"status": "blocked",
"priority": "high",
"tags": ["mobile"],
"workflow_id": "wf-1",
"pipeline_id": 42,
"created_at": "2026-05-11T14:00:00Z",
"updated_at": "2026-05-11T14:01:00Z"
}
],
"pipelines": [
{
"id": 42,
"project": "services/loom-core",
"ref": "codex/mobile",
"status": "failed",
"current_stage": "test",
"failed_job_count": 1,
"web_url": "https://gitlab.example/pipelines/42"
}
],
"task_count": 1,
"pipeline_count": 1
}
Source: internal/hud/domain/mobile/handler_agents.go
POST /api/mobile/v1/sessions
Create/start a new session. Scope: mobile:session:create.
Request body:
{
"agent_id": "codex",
"namespace": "loom-core/mobile",
"description": "Investigate issue #123",
"auto_recall": true
}
| Field | Type | Required | Description |
|---|---|---|---|
agent_id | string | yes | Agent to create the session for |
namespace | string | no | Session namespace |
description | string | no | Human-readable description |
auto_recall | bool | no | Auto-recall previous context |
Response: Delegates to internal handleAgentSessionStart handler. Returns the session envelope from the agent-context bridge.
Audit: Logs session_create with agent_id and namespace targets.
Source: internal/hud/domain/mobile/handler_sessions.go
POST /api/mobile/v1/sessions/{session_id}/end
End an active session. Scope: mobile:session:end.
Request body:
{
"summarize": true
}
| Field | Type | Required | Description |
|---|---|---|---|
summarize | bool | no | Generate summary on end |
Response: Delegates to internal handleAgentSessionEnd handler. Returns the session-end result from the agent-context bridge.
Error cases:
400 bad_request— missingsession_idor invalid body
Audit: Logs session_end with session_id and summarize targets.
Source: internal/hud/domain/mobile/handler_sessions.go
GET /api/mobile/v1/events/stream
SSE endpoint for mobile realtime feed. Scope: mobile:read.
Delegates to the existing /api/events SSE handler after auth validation.
Event allowlist in v1:
hud.fleethud.healthhud.pipelinehud.workflowshud.streamagent.session.startagent.session.endagent.session.reapedagent.session.stats.updatedagent.heartbeat
Source: internal/hud/domain/mobile/handler_ops.go
GET /api/mobile/v1/alerts/policy
Canonical event-to-severity-interruption-action matrix. Scope: mobile:read.
Mobile clients use this to synchronize notification behavior with the server-defined policy. The matrix defines how each SSE event type maps to severity, iOS interruption level, and allowed quick-actions.
Response data:
{
"version": "v1",
"policy": [
{
"event_type": "hud.health",
"severity": "critical",
"interruption_level": "time_sensitive",
"title": "Server Down",
"allowed_actions": ["view_dashboard", "acknowledge"],
"conditional": true
}
]
}
Policy entry schema:
| Field | Type | Description |
|---|---|---|
event_type | string | SSE event type this rule applies to |
severity | string | info, warning, or critical |
interruption_level | string | passive, active, time_sensitive, or critical |
title | string | Display title for the alert |
allowed_actions | array | Safe actions: view_session, view_dashboard, acknowledge |
conditional | bool | Whether severity depends on event payload (e.g., health events) |
Interruption level semantics (maps to iOS UNNotificationInterruptionLevel):
| Level | Behavior | Use case |
|---|---|---|
passive | Silent; added to list without sound/banner | Info-level events (session start/end, approvals) |
active | Default notification (sound + banner) | Warnings requiring attention (reaped, degraded) |
time_sensitive | Breaks through Focus/DND | Critical operational events (server down) |
critical | Reserved; not used in v1 | Emergency alerts (future) |
Action constraints: All actions are read-only navigation operations. No mutation actions are permitted from alert quick-actions to maintain v1 scope discipline.
Source: internal/hud/domain/mobile/handler_ops.go, internal/hud/domain/mobile/helpers.go
POST /api/mobile/v1/push/register
Register a device push token for APNs or FCM notifications. Scope: mobile:push. Gated by --mobile-push-enabled feature flag (returns 404 when disabled).
Request body:
{
"token": "device-push-token-hex-string",
"platform": "apns"
}
| Field | Type | Required | Description |
|---|---|---|---|
token | string | yes | Device push token from APNs or FCM |
platform | string | yes | "apns" or "fcm" |
Response data:
{
"registered": true,
"registration_id": "reg_a1b2c3d4"
}
Error cases:
400 bad_request— missing/empty token, missing/invalid platform (must beapnsorfcm)401 unauthorized— invalid bearer token403 forbidden— missingmobile:pushscope404 not_found— push notifications not enabled (feature flag off)
Behavior: Re-registering the same token updates the last_used timestamp and device ID association. Tokens are stored in-memory for v1; persistent storage is planned for M5.
Source: internal/hud/domain/mobile/handler_push.go
POST /api/mobile/v1/push/unregister
Remove a device push token. Scope: mobile:push. Gated by --mobile-push-enabled feature flag.
Request body:
{
"token": "device-push-token-hex-string"
}
Response data:
{
"removed": true
}
The removed field is true if the token existed and was removed, false if the token was not found.
Error cases:
400 bad_request— missing/empty token401 unauthorized— invalid bearer token403 forbidden— missingmobile:pushscope404 not_found— push notifications not enabled (feature flag off)
Source: internal/hud/domain/mobile/handler_push.go
Push Retry and Payload Policy (MBL-7)
The push notification infrastructure enforces the following contracts:
Retry classification by HTTP status:
| Status | Action | Reason |
|---|---|---|
| 2xx | No retry | Success |
| 400 | No retry | Bad request (fix payload) |
| 401/403 | No retry | Auth/provisioning error |
| 404 | Invalidate token | Device not registered |
| 410 | Invalidate token | Token expired/unregistered |
| 429 | Retry after delay | Rate limited (honor Retry-After or 30s default) |
| 5xx | Retry with backoff | Server error |
Backoff policy: Exponential (2^n * 1s base, capped at 5m, max 5 retries).
Payload guardrails: APNs and FCM payloads are validated against 4096-byte limits. Oversized payloads are truncated at the body field with UTF-8-safe "..." suffix. Truncation never breaks multi-byte characters.
Token lifecycle: Invalid tokens (404/410 from APNs) are automatically removed from the device token store. Stale tokens are pruned by a background reaper every hour using a 30-day idle cutoff (via CleanupStale).
Source: internal/hud/mobile_push.go (ClassifyPushResponse, PushBackoffConfig, PushPayload.ValidateAndTruncate, DeviceTokenStore)
POST /api/mobile/v1/admin/revoke
Revoke a mobile operator token at runtime. Protected by admin token (X-Admin-Token header), not by mobile bearer auth.
Request headers:
X-Admin-Token: <admin_token>(required)
Request body:
{
"token": "<mobile_token_to_revoke>"
}
Response data:
{
"revoked": true
}
Error cases:
400 bad_request— missingtokenfield401 unauthorized— invalid admin token403 forbidden— admin token not configured
Audit: Logs token_revoke action.
Source: internal/hud/domain/mobile/handler_push.go
Recovery SLO Telemetry (MBL-5)
Cross-surface publishing of the in-app SSE disconnect-to-recovered SLO telemetry
(ConnectionHealthMonitor, .loom/137) so the HUD can show fleet recovery health.
All three slices are complete: backend ingestion + aggregation (slice 1, .loom/138),
the iOS uploader (slice 2, .loom/139), and the HUD recovery-SLO tile (slice 3,
.loom/140).
POST /api/mobile/v1/telemetry/recovery
Ingest one device's rolling window of disconnect-to-recovered durations. Scope:
mobile:telemetry. Off by default — operators must add mobile:telemetry to
HUD_MOBILE_OPERATOR_SCOPES to enable ingestion (mirrors mobile:agent:spawn).
Keyed by the X-Device-ID header; rate-limited in the mutation class.
Request body:
{
"samples": [5.2, 8.1, 22.3],
"slo_target_seconds": 30
}
samples(required): recovery durations in seconds; each must be positive and finite. Non-finite/≤0 values are dropped; the window is truncated to the most recent 50 (matching the in-appmaxRecoverySamples).slo_target_seconds(optional): the device's p95 SLO target. Defaults to30(one poll-fallback cycle); clamped to(0, 3600].
Response data:
{
"accepted": true,
"device": {
"device_id": "device-123",
"sample_count": 3,
"mean_seconds": 11.87,
"p95_seconds": 22.3,
"slo_target_seconds": 30,
"meets_slo": true,
"updated_at": "2026-06-08T12:00:00Z"
}
}
Per-device p95_seconds uses the same nearest-rank formula as the iOS client
(rank = ceil(0.95·n), index = clamp(rank-1)) so the published figure matches
what operators see in-app. Re-ingesting from the same X-Device-ID replaces that
device's snapshot.
Error cases:
400 bad_request— invalid JSON, emptysamples, or no positive finite sample400 missing_device_id—X-Device-IDheader absent401 unauthorized— invalid bearer token403 forbidden— missingmobile:telemetryscope429 rate_limited— mutation-class rate limit exceeded
iOS companion client (slice 2, .loom/139): the app's RecoveryTelemetryUploader
posts its rolling ConnectionHealthMonitor.recoverySampleSeconds window on each
transient-outage recovery. Resending the full window is idempotent (the backend
replaces the device snapshot). The uploader dedups an unchanged window, stops
permanently after a 403 (scope off by default), and treats 429/other errors as
transient — keeping the write ingress disciplined while the scope remains unenabled.
GET /api/mobile/v1/telemetry/recovery
Fleet recovery-SLO rollup pooled across all reporting devices. Scope: mobile:read.
Returns zeros with meets_slo: true when no device has reported yet.
Response data:
{
"device_count": 2,
"total_samples": 6,
"fleet_mean_seconds": 7.5,
"fleet_p95_seconds": 10,
"slo_target_seconds": 30,
"devices_meeting_slo": 2,
"meets_slo": true,
"devices": [
{"device_id": "dev-a", "sample_count": 3, "mean_seconds": 6, "p95_seconds": 7, "slo_target_seconds": 30, "meets_slo": true, "updated_at": "2026-06-08T12:00:00Z"}
],
"updated_at": "2026-06-08T12:00:01Z"
}
fleet_mean_seconds / fleet_p95_seconds are computed over the pooled samples
of all devices (a true nearest-rank percentile of the combined window), not an
average of per-device summaries. meets_slo is fleet_p95_seconds ≤ slo_target_seconds.
Audit: Ingestion logs telemetry_recovery_ingest with sample_count.
GET /api/telemetry/recovery (HUD operator web UI)
Same fleet rollup as the mobile read endpoint above, but served to the HUD
operator web UI: same-origin and session-trusted — no bearer token or scope
(the browser has no mobile token). The body is the raw RecoveryAggregate
(no {ok,data,meta} envelope), matching the HUD-internal /api/* convention the
Svelte stores consume. It reads the identical in-memory store, so the figure
matches what the companion publishes and the mobile read returns. Consumed by the
RecoverySLOCard tile on the HUD Overview panel (slice 3, .loom/140).
Source: internal/hud/domain/mobile/handler_telemetry.go, recovery_telemetry.go;
HUD tile internal/hud/frontend/src/lib/components/RecoverySLOCard.svelte
Rate Limiting
Mobile API endpoints enforce per-actor, per-minute rate limits:
| Category | Default limit | Config flag | Env var |
|---|---|---|---|
Mutation (POST) | 10 req/min | --mobile-rate-limit-mutation | HUD_MOBILE_RATE_LIMIT_MUTATION |
Read (GET) | 60 req/min | --mobile-rate-limit-read | HUD_MOBILE_RATE_LIMIT_READ |
- Actor is identified by remote IP address.
- Set limit to
0to disable rate limiting for that category. - Rate-limited requests receive
429 Too Many Requestswith error coderate_limited.
Device Identity
Mobile clients may include an X-Device-ID header on all requests. When present, the device ID is included in audit log entries for mutation operations. Maximum length: 128 characters (truncated if longer).
Internal Mapping
Mobile endpoints delegate to existing internal surfaces:
| Mobile endpoint | Internal handler |
|---|---|
POST /sessions | handleAgentSessionStart |
POST /sessions/{id}/end | handleAgentSessionEnd |
GET /sessions | AgentBridge.Sessions() |
GET /sessions/{id} | AgentBridge.Sessions() + filter |
GET /sessions/{id}/events | EventLog.All() + filter |
GET /events/stream | handleSSE |
GET /dashboard | FleetMonitor.Snapshot() + HealthMonitor.Summary() + EventLog.All() |
GET /ping | Direct response |
GET /alerts/policy | mobileAlertPolicyMatrix() |
POST /push/register | DeviceTokenStore.Register() |
POST /push/unregister | DeviceTokenStore.Invalidate() |
The mobile API layer normalizes these into stable DTOs with the mobileEnvelope wrapper.
Idempotency and Retry
POST /sessionsshould be idempotent for same active session context.POST /sessions/{id}/endshould be safe to retry; “already ended/not found” should not cause destructive side effects.- Mobile client can retry transient network failures with bounded backoff.
Pagination and Limits
- Default
per_page: 30 - Max
per_page: 100 - Cursor-based pagination can be added later if list volume grows.
Audit Requirements
All mutation endpoints must record:
- actor id
- device id
- source mode (
lanorgateway) - endpoint/action
- target ids (session/agent)
- outcome + error (if any)
Sources
docs/MOBILE_COMPANION_AUTH_BOOTSTRAP.md— consolidated auth bootstrap decision, flow descriptions, and LAN/gateway comparisoninternal/hud/domain/mobile/— mobile v1 handlers, envelope types, auth helpers, and route registrationinternal/hud/routes.go— top-level HUD route registration and mobile domain mountinginternal/hud/bridge/agent.go:30-41—SessionInfostructinternal/hud/monitor/fleet.go:18-63—FleetSnapshotstructinternal/hud/monitor/health.go:98-105—HealthSummarystructinternal/hud/eventlog.go:10-16—TimelineEntrystructdocs/STREAMABLE_HTTP.md.loom/20-product-spec.md