This is the canonical Phoenix-hosted Scoria flow for the Keystone public runtime surface. It is derived from the existing runtime integration behavior in test/scoria/runtime_integration_test.exs, not from a separate sample app or a speculative architecture.
Keep the canonical adoption order boring: identity -> start -> inspect -> resume.
What this guide shows
- normalize request and session context with
Scoria.identity/1 - start a run through
Scoria.start_run/2 - persist
run_idas the exact durable handle for one run - reuse
session_idfor continuity across turns - inspect progress with
Scoria.get_run/1andScoria.list_runs_for_session/1 - link
/scoria/workflows/:run_idas operator evidence for that same run - resume a paused approval flow through
Scoria.resume_run/2
Core rule: session_id is not run_id
Use session_id to group related turns in your host app. Use run_id to inspect or resume one exact Scoria execution.
- same conversation, new turn: reuse
session_id, create a fresh run - paused run: resume only by its exact
run_id
That distinction is the main contract to preserve in your Phoenix app.
Controller-triggered start
Start from a normal controller action. Normalize the Phoenix edge state first, then call the top-level Scoria facade.
defmodule MyAppWeb.AssistantController do
use MyAppWeb, :controller
def create(conn, %{"prompt" => prompt}) do
identity =
Scoria.identity(%{
actor_id: conn.assigns.current_user.id,
tenant_id: conn.assigns.current_account.id,
session_id: get_session(conn, :assistant_session_id),
metadata: %{"channel" => "web"}
})
{:ok, started} =
Scoria.start_run(identity,
root_role_id: "executor",
initial_step: %{
sequence: 1,
kind: "approval",
role_id: "executor",
status: "queued"
},
runtime: [
metadata: %{
"payload" => %{"prompt" => prompt}
}
],
handlers: %{"approval" => {MyApp.RuntimeHandlers, :wait_for_approval}}
)
conn
|> put_session(:last_scoria_run_id, started.run_id)
|> redirect(to: ~p"/assistant/runs/#{started.run_id}")
end
endIf your identity already lives in assigns or session maps, the same module exposes narrower helpers:
identity_from_assigns = Scoria.Identity.from_conn_assigns(conn.assigns)
identity_from_session = Scoria.Identity.from_session(get_session(conn))Use those helpers only when they make the edge boundary clearer. The important part is that the controller hands Scoria one normalized identity before the run starts.
Persist the exact run_id
Persist started.run_id anywhere your host app already tracks ongoing work: session, database row, job record, or a conversation table. That run_id is the exact handle for:
Scoria.get_run/1Scoria.resume_run/2/scoria/workflows/:run_id
Do not try to resume from session_id alone.
Inspect progress from the host app
Your app can inspect one run directly or list all runs that share the same session_id.
{:ok, summary} = Scoria.get_run(run_id)
same_session_runs = Scoria.list_runs_for_session(session_id)Use Scoria.get_run/1 when the host app needs the current state of one exact execution. Use Scoria.list_runs_for_session/1 when you want to show a session timeline across multiple turns.
Operator evidence page
The operator page for one run is:
/scoria/workflows/:run_idLink to it from your host app when an operator needs traceable evidence for the same durable run:
redirect(conn, to: ~p"/scoria/workflows/#{run_id}")Treat that page as operator evidence, not as the source of your product's business truth.
Bounded handoffs branch from the same runtime lane
If the core runtime path is already working and a draft needs a bounded review, branch from the same identity and run_id model instead of starting a second onboarding path.
The host app owns identity, escalation policy, prompt or draft selection, and projected-context selection.
Scoria owns durable run creation, projected-context validation, queued delegated child creation, and curated readback through Scoria.get_run_detail/1.
Use Scoria.start_handoff_run/3 only for that explicit bounded delegation branch.
def create(conn, %{"draft_answer" => draft_answer}) do
identity =
Scoria.identity(%{
actor_id: conn.assigns.current_user.id,
tenant_id: conn.assigns.current_account.id,
session_id: get_session(conn, :assistant_session_id),
metadata: %{"channel" => "web"}
})
{:ok, started} = Scoria.start_run(identity, root_role_id: "executor")
conn = put_session(conn, :last_scoria_run_id, started.run_id)
if needs_bounded_review?(draft_answer) do
{:ok, handoff_run} =
Scoria.start_handoff_run(identity, "critic",
root_role_id: "planner",
delegated_kind: "review",
handoff_input: %{"brief" => "Review the draft answer for policy and accuracy"},
projected_context: %{
"task" => "policy-and-accuracy review",
"draft_answer" => draft_answer
},
handlers: %{"review" => {MyApp.RuntimeHandlers, :review}}
)
conn = put_session(conn, :last_scoria_handoff_run_id, handoff_run.run_id)
{:ok, detail} = Scoria.get_run_detail(handoff_run.run_id)
delegated = detail.delegated_handoffs
started.run_id != handoff_run.run_id
redirect(conn, to: ~p"/assistant/runs/#{handoff_run.run_id}")
else
redirect(conn, to: ~p"/assistant/runs/#{started.run_id}")
end
end
defp needs_bounded_review?(draft_answer) do
String.contains?(draft_answer, "policy")
endUse Scoria.get_run_detail/1 when the host app or support path needs the curated delegated evidence surface, and use /scoria/workflows/:run_id when an operator needs the same run's Delegated Evidence section.
session_id groups related host turns; run_id names one exact Scoria execution.
Runtime-to-handoff verifier
When this bounded delegation branch is wired, verify it with:
mix test.runtime_to_handoff
Keep mix test.adoption as the default-lane verifier; this lane is only the bounded runtime-to-handoff escalation proof.
This verifier follows the same run-detail path shown above: Scoria.get_run_detail/1 returns delegated_handoffs for the run that appears on /scoria/workflows/:run_id.
Resume after approval
When a run pauses for approval, resume that exact run by the stored run_id.
{:ok, resumed} =
Scoria.resume_run(run_id,
handlers: %{"approval" => {MyApp.RuntimeHandlers, :succeed}}
)The resumed run keeps the same run_id. Resuming does not create a new run.
Same session, fresh run
When the user comes back for another turn in the same conversation, reuse the same session_id and start a new run:
identity =
Scoria.identity(%{
actor_id: conn.assigns.current_user.id,
tenant_id: conn.assigns.current_account.id,
session_id: get_session(conn, :assistant_session_id)
})
{:ok, next_run} = Scoria.start_run(identity, root_role_id: "executor")
next_run.session_id == session_id
next_run.run_id != run_idThis is the intended continuity model:
session_idgroups the conversationrun_idnames one exact execution
Verification checklist
After wiring the flow:
- Start a run through your controller with
Scoria.start_run/2. - Store the returned
run_id. - Read it back with
Scoria.get_run/1. - Open
/scoria/workflows/:run_idand confirm the same run is visible there. - If the run pauses for approval, call
Scoria.resume_run/2with the storedrun_id. - Start another turn with the same
session_idand confirm it produces a differentrun_id.
What this guide does not require
- LiveView-first orchestration
- background-job-first orchestration
- direct workflow internals as the normal app entrypoint
- pgvector or the knowledge lane just to prove the runtime path
Use the public Scoria facade first. Expand into advanced runtime or knowledge features only after this core lane is working.