Interactions
Source: docs/architecture/interactions.md at origin/platform (c2ce9cf), synced 2026-09-26. Do not edit this page here; change the source and run yarn sync:studio.
One sequence diagram and a short narrative per flow, as the code on platform runs it (Studio API a78212f,
data-app-runtime b21a421, studio-snapshot-worker 2241304, agent-service 95c607eb4). Route shapes are in
API.md (in the bicycle-studio-api repository, not published); identities and grants in security.md. Participant names are the systems in
overview.md.
a. Function Try (POST /api/studio/v1/functions/{name}/versions/{n}:run)
DraftRunner (runtime_broker/draft.py) dispatches by kind. Code goes through the full broker path: concurrency
leases and compute-unit counters in Redis, an rt-invoke/1 grant pinned to the deployment, placement on runtime-server's
exec role, one fresh gVisor pod per attempt (there is no warm pool; a cold spot node can add minutes and answers
pool_cold). The pod's capability calls arrive at runtime-server's fn/1 gateway; cache and blob are served there,
everything else is forwarded to Studio (runtime-caps with X-Invoke-Grant in service mode, or the viewer channel the
Studio pod long-polls). llm and classify functions run in runtime-server's process (/v1/functions:run), calling
agent-service or Jev; Studio records the result inline. An agent Try is an agent run (flow h) started with the inline
spec. Every result is an invocation record with events; draft llm/agent runs still use the legacy inv_draft_ /
inv_agent_ ids (changing under #39).
b. A function called by ref from an app (POST /api/data-apps/{app}/fn/{local}[:submit])
The app never holds a credential and never names a ref: it calls a local name declared in manifest.functions (pinned
fn:{tenant}/{name}@{n} or wf:{tenant}/{slug}@{n}, @latest refused, widening imports reviewed by a person at app
publish). The reuse key is sha256 of (tenant, app, local name, ref@version, viewer subject, canonical input); it is
never shared across viewers and lives in Redis plus a durable doc. Agent invocations do not take broker leases; they
take the agent-runs tenant slot (max_concurrent_runs, default 4) and queue FIFO per tenant when it is full. Dispatch is
pulled by the caller's own reads because the run needs the viewer's credential, which Studio never stores; a caller that
stops reading keeps its place until the 600 s TTL. A wf: import ignores reuse (the workflow joins same-date runs itself).
The bridge and bda.fn.call/watch/cancel live in the EBG copy app and the workflow guide; the public template does not
ship them yet.
c. A function or workflow called by an agent as a tool
Broker.agent_tool answers {ref, description, input_schema} for each granted fn: ref (expose.agents) and for wf:
refs a workflow exposes to agents; agent-service presents them as fn_<name> / wf_<slug> tools. Each call comes back
to Studio as a normal by-ref invocation carrying the agent run id, so the child is its own invocation with
links.agent_run_id and the parent's id. A nested agent run is counted against its parent's slot when the parent is a
live run of the same tenant and person held by the same agent-service process; anything else takes a slot as usual. The
nested claim is caller-asserted today (security item 13). A child invocation started from an agent shows only its own
events, with a link to the parent run (32294fb).
d. Called over MCP (/mcp/functions, SSE progress, cancel)
The MCP server is the official SDK's streamable HTTP, stateless; /mcp and every toolset answer JSON except
/mcp/functions, which answers SSE so a progressToken gets one notifications/progress per invocation event. Without a
token the call waits up to BSA_FUNCTIONS_MCP_MAX_WAIT_SECONDS (55) and then returns a pointer to poll. Cancel arrives on
a new connection; the request id is resolved through the Redis mcp-call record. OAuth clients cannot see these tools
yet because the advertised scopes stop at apps:* (security item 12).
e. A workflow run
Three triggers, one start path (WorkflowService._start → RemoteRunner.start): a person's "Run now" (surface ui,
identity viewer); a cron fire (scheduling-service → the snapshot worker's data-app-workflow-tick → the tick route, once
per (trigger, local minute), identity the tenant support session); an app or agent calling the workflow by ref
(surface app|agent, the caller as viewer; the runner's trigger vocabulary is closed, so a by-ref call reaches it as
manual while who and from where stay on Studio's run record and invocation, 031c6aa). Studio plans through runtime-server (BSA_WORKFLOW_ENGINE=runtime), passing
the tenant facts so mail and action rules fail at save or plan, then pushes the revision and starts the run on the
runner with the wf-run/1 grant. The runner is the only executor (BSA_WORKFLOW_RUNNER=remote). Nodes that need
Studio-held credentials call Studio internal routes with the grant only; Studio answers as the tenant's support session
after checking the grant's jti against the run. The runner's outbox is relayed into the run's invocations (root plus a
child per node partition) so GET /invocations/{id}/events and GET /runs?kind=workflow see one trace. Try runs
(env: try:<user>, any non-published revision) write versions but move no pointer and dry-run every send.
f. Snapshot node → studio-snapshot-worker
The runner never holds bytes: Studio runs the same one-shot pipeline as a schedule's "Send test" (capture_png, no
delivery), as the tenant service identity, and hands back metadata; the PNG becomes a report artifact whose blob the
send step later attaches and inlines. Only tabs[0] is captured, PNG only. A capture of an app not shared with the whole
tenant fails before it starts (snapshot_app_not_shared), and a page that never reached its panels (ready.ok=false
with no partial panels) fails the step (snapshot_render_failed); a send that reads a snapshot that did not succeed this
run is skipped rather than sent with stale data. Errors 503 (snapshot_timeout, snapshot_unavailable) are retried by
the runner (2); 422 codes are final.
g. Send step: approval, mail or action, ledger, failure routing
Dedupe is the runner's per-action ledger keyed by the rendered idempotency_key (one open attempt per key; a re-run
joins or supersedes); Studio never writes it. Approval is optional since contract 1.5.0: auto sends as the step
runs (AI-written input under it is a warning and a gated change a person acknowledges at publish); manual needs a
person whose decision is bound to the exact payload hash; notify.on_success: send approves automatically only on a
full success. An action send under auto is refused at save, at plan and again at :send unless the tenant admin
marked the action auto_allowed and the deployment allows it (BSA_WORKFLOW_ACTIONS_ALLOWED, empty on preview and
by default). Email goes out as the tenant's service identity (#36) through alert-service, which returns no Message-ID,
so a timeout is unknown and a person decides. Failure routing mails one recipient per failed or partial prod run;
deterministic classes use the routes table, everything else is Jev's choice among role names, never addresses.
h. Agent run (POST /api/data-apps/{app}/agent-runs, POST /api/studio/v1/agents/{id}/runs)
Studio holds the tenant's limits document (staff-written) and signs the effective limits into every run; author numbers
can only lower them; agent-service is the enforcer where the money is spent. Slots exist on both sides: Studio's
per-tenant slot in Redis, agent-service's tenant and global in-flight pools (per pod on preview, since
AGENT_RUNS_SLOTS is unset there). Writes are never agent tools: an agent reads through tool-service and hands off to a
workflow send step. agent-service does not verify the caller assertion today (security item 1).
i. Data-app chat with functions
The transcript lives only in agent-service; Studio keeps a small per-user thread index and the model card it prefetched.
Functions are inherited by default (settings.chat.functions: inherit, owner/admin may set off; the API exists, the
settings UI control does not). Tool calls from chat are ordinary invocations with surface: chat.
j. Detect / Explain (ad hoc)
Analysis is a separate job model: Redis-only records, its own slots and budget, not an invocation and not in /runs
(#39 v1.5 mirrors it). The workflow detect / explain kinds are status: draft and cut from v1; the runner's
Executors.runnable() would attempt them if a document had them, while the registry advertises them not_runnable.
k. Data-app Schedule Mode (snapshot worker)
Schedule Mode is the older, live path (#4): the worker's own Temporal workflow does capture, summary and delivery, all
through Studio's internal routes with a support session traded from the HMAC grant (Run now and Send test use a
one-shot grant and write started.json first). It is not a workflow run today; the plan is to turn each schedule into a
generated workflow (snapshot node → send node) and keep the worker as the render backend only.
l. Publish with human review, disable / enable
Gated changes (a new send, a changed to or approval, a widened import, ai_text_unreviewed under auto) need a
person: the review hash must have been shown to that person for that revision within 900 s; MCP workflow_publish and
function_publish refuse gated changes and hand back the review link. Functions follow the same review and TTL
(BSA_FUNCTIONS_REVIEW_TTL_SECONDS). Disable is the soft delete (#44): pinned callers keep resolving the ref
(function.disabled: true on their roots), starts are 409, and the confirm dialog lists dependents from
GET …/dependents. Approvals the runner holds are not closed from Studio (unchecked: ["remote_approvals"]); they
expire on their own.