Skip to Content
DocsConceptsRun vs Session vs RunContext

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 RunResult at the end with output, 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

RunSessionRunContext
Created whenagent.run() is calledFirst agent.run(session_id=...) with that idAt the start of every agent.run()
Lives untilThe call returnsYou delete the dataThe call returns
Identified byrun_id (auto)session_id (user-chosen)both
Visible to toolsvia RunContextvia RunContext.session_idvia get_run_context()
Persistentonly via runtime journalyes (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, StandardBudget tracks per-user totals.
  • Audit log. Every entry carries user_id as 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.

Last updated on