Run vs Session vs RunContext
Three terms, three different scopes. Getting them straight makes the rest of the API obvious.
Run
A Run is one agent.run() (or agent.stream()) call. It has:
- An auto-generated id (a ULID, returned in
RunResult.id). - A start time, end time, and aggregate
Usage. - A position in the runtime journal (used for replay).
- A
RunResultat the end withoutput,turns,tokens_in,tokens_out,cost_usd,interrupted,interruption_reason.
You don’t manage the run id. The framework generates one per call; you get it back in the result.
Session
A Session is a long-lived conversation that may span many runs.
It’s keyed by session_id. A string you choose:
await agent.run("Hi, I'm Alice.", session_id="conv_42", user_id="alice")
await agent.run("What did I just say?", session_id="conv_42", user_id="alice")
# → "You said you're Alice."Same session_id = the framework rehydrates the prior turns from
memory (Episode store) into the message stack. Different
session_id = a fresh conversation.
For resumability. Restart the process, pick up where you left off ,
combine session_id with a journaled Runtime:
agent = Agent("...", model="...", runtime=SqliteRuntime("./journal.db"))
# First attempt:
result = await agent.run("complex task", session_id="task-2026-05-08")
# Process crashed. Later:
result = await agent.resume("task-2026-05-08", "complex task")agent.resume(session_id, prompt) is sugar for
agent.run(prompt, session_id=session_id). The runtime’s journal
keys on (session_id, step_name); replays cached results, executes
only un-completed steps.
RunContext
A RunContext is the read-only bundle of scope a Run carries:
@dataclass
class RunContext:
run_id: str
session_id: str | None
user_id: str | None
metadata: dict[str, Any]It lives in a Python contextvar so anything inside a tool dispatch can read it without explicit plumbing:
from loomflow import get_run_context, tool
@tool
async def fetch_orders() -> str:
ctx = get_run_context()
return await db.query("orders", user_id=ctx.user_id)get_run_context() returns the live RunContext for the active
run. When called outside of a run, it raises.
Three scopes, three lifetimes
| Run | Session | RunContext | |
|---|---|---|---|
| Created when | agent.run() is called | First agent.run(session_id=...) with that id | At the start of every agent.run() |
| Lives until | The call returns | You delete the data | The call returns |
| Identified by | run_id (auto) | session_id (user-chosen) | both |
| Visible to tools | via RunContext | via RunContext.session_id | via get_run_context() |
| Persistent | only via runtime journal | yes (Memory backend) | no |
What user_id is, exactly
user_id is a kwarg on agent.run() and a field on RunContext.
The framework forwards it to:
- Memory. Every recall and write partitions by
user_id. - Budget,
StandardBudgettracks per-user totals. - Audit log. Every entry carries
user_idas a top-level field. - Permissions,
permissions.check(call, user_id=...). - Telemetry. Every metric is tagged with
user_id. - Tools,
get_run_context().user_id.
You set it once on the agent.run() call; the framework propagates
everywhere. No manual plumbing through tool signatures.
Why a contextvar, not a tool argument? Tools shouldn’t have to
re-declare scope on every signature. Threading user_id through ten
tools’ arguments is exactly the kind of boilerplate that produces
namespace-leak bugs in hand-rolled frameworks. The contextvar makes
the right thing happen by default.