Sessions¶
A Session is a live, multi-turn conversation. Open one with
agent.start_session(), send messages, and the context carries across turns.
from caw import Agent
agent = Agent(provider="claude_code", model="opus", reasoning="high")
agent.set_system_prompt("You are a security reviewer.")
with agent.start_session() as session:
turn1 = session.send("Review src/auth.py for vulnerabilities")
print(turn1.result)
turn2 = session.send("Now check src/api.py")
print(turn2.result)
# session.end() runs on exit and returns the full Trajectory
Using the session as a context manager is the easy path — __exit__ calls
session.end(), which finalizes the trajectory, persists it, and stops any
tool servers. If you don't use with, call session.end() yourself.
Where the agent runs — cwd¶
Every agent CLI takes its project root from the working directory: it is where
claude looks for CLAUDE.md, where relative paths resolve, and — for codex —
which tree --sandbox workspace-write may write to. Pass cwd to put a
session somewhere other than your own process's directory:
agent = Agent(provider="codex", sandbox="workspace-write", cwd="/tmp/job-42")
agent.completion("Write a summary of this repo into notes.md")
It works the same on every provider, and on both the headless and interactive
paths. A cwd that is not a directory raises NotADirectoryError up front —
deliberately, because subprocess reports a missing working directory as
FileNotFoundError, which each provider would otherwise translate into "the
CLI is not installed".
Options a provider does not support¶
Providers read the options they understand by name; anything else is reported:
Agent(provider="claude_code", extra_config={"a": 1}).completion("hi")
# WARNING claude_code: ignoring unsupported session option(s): extra_config —
# this backend does not implement them, so they will have no effect.
An option a backend does not implement used to be dropped in silence, so a
typo — or a codex-only option handed to claude — left the call working and
quietly not doing what it said.
extra_config (codex only)¶
codex takes arbitrary config overrides on the command line. Pass a flat dict of
dotted keys and caw turns each into a -c key=value flag with a TOML-encoded
value:
agent = Agent(
provider="codex",
sandbox="workspace-write",
cwd=work_dir,
extra_config={
"sandbox_workspace_write.exclude_slash_tmp": True,
"sandbox_workspace_write.exclude_tmpdir_env_var": True,
},
)
One-shot vs. session¶
For a single message, agent.completion(message) is a convenience wrapper that opens a
session, sends once, and ends it:
Inspecting progress mid-session¶
session.trajectory is available during the session, not just after:
with agent.start_session() as session:
session.send("Remember the number 42.")
session.send("What number did I just tell you?")
traj = session.trajectory
print(f"Turns: {traj.num_turns}")
print(f"Total tool calls: {traj.total_tool_calls}")
print(f"Total tokens: {traj.usage.total_tokens}")
Async sends¶
send_async() runs the blocking send in a thread and processes overlapping calls in FIFO
order, so you can do async work while a turn is in flight:
import asyncio
task = asyncio.create_task(session.send_async(prompt))
while not task.done():
# ... do other async work ...
await asyncio.sleep(0.5)
turn = await task
Interactive mode¶
agent.interactive(prompt) hands control to the user's terminal — stdin/stdout/stderr are
inherited so the user talks to the agent directly, while caw captures a copy of the output.
All three providers support it (Claude Code, Codex, and opencode), each launching its own
full-screen TUI with your initial prompt.
Pass select_provider=True to choose which backend to launch at runtime: caw shows an
arrow-key menu of the installed providers (↑/↓ to move, Enter to choose, q/Esc to
cancel) and launches the one you pick, ignoring the agent's configured provider. Cancelling
the menu returns an InteractiveResult with exit code 130 without launching anything.
caw.installed_providers() exposes the same list (name + provider) for your own menus.
"""Interactive mode — launch the agent and let the user take over.
Pass ``select_provider=True`` to pick which installed provider to launch from
an arrow-key menu (↑/↓ to move, Enter to choose, q/Esc to cancel) instead of
using the agent's configured provider.
"""
import sys
from caw import Agent
def main():
# `select_provider` is taken from the first CLI arg: `python interactive.py pick`.
pick = len(sys.argv) > 1 and sys.argv[1] in ("pick", "select", "--select-provider")
agent = Agent()
prompt = (
"List the directories in the current directory, then wait for me to tell "
"you which one to count the Python files in."
)
result = agent.interactive(prompt, capture_bytes=4096, select_provider=pick)
print(f"\nExit code: {result.exit_code}")
if result.session_id:
print(f"Session ID: {result.session_id}")
print(f"Captured {len(result.output)} chars of terminal output")
if __name__ == "__main__":
main()
Auto-wait on usage limits¶
By default, when a provider reports a usage limit mid-session, send() sleeps until the limit
resets and then resumes automatically — transparently to you. Disable it per agent with
Agent(..., auto_wait=False) or globally with CAW_AUTOWAIT=0.
Whether or not it waits, the limit is recorded on the turn as data (turn.usage_limit, a
caw.models.UsageLimit) and handed to an on_usage_limit(limit, remaining_seconds) callback
before the wait starts and periodically while it runs — so an embedder running many sessions
against one account can stop sending work there at detection, not hours later:
def on_limit(limit, remaining):
if limit.kind == "weekly": # days out: re-queue the work rather than park a worker
scheduler.pause_account(until=limit.resets_at)
guess = " (a guess)" if limit.timing_source == "estimated" else ""
print(f"{limit.kind} limit, {remaining // 60} min left{guess}")
session = agent.start_session(on_usage_limit=on_limit, max_usage_wait=6 * 3600)
| Field | Meaning |
|---|---|
kind |
session (the rolling few-hour window), weekly (the subscription's week), or unknown. Read from the provider's wording, never inferred from the duration — a long session wait and a short weekly one overlap. |
wait_seconds / retry_at |
How long caw intends to wait, and that delay resolved against the clock at detection. Both include a safety buffer, and are a flat default when the message could not be timed. |
timing_source |
parsed when the reset was read out of the message; estimated when the wait is the default. Treat an estimated wait as a guess, not a fact. |
resets_at |
The provider's own reset instant, ISO-8601 UTC, with no buffer. Empty when the message stated none. |
provider, raw |
Which provider reported it, and the message it was read from. |
max_usage_wait (or CAW_MAX_USAGE_WAIT, in seconds) caps a single wait: a limit asking for
longer raises caw.agent.UsageWaitTooLong instead of sleeping, carrying the UsageLimit so a
handler can branch on kind. Claude Code's weekly limit is timed honestly — days, not a
one-hour default — so with a cap set, a weekly limit that used to be silently slept through an
hour at a time now raises. That is the cap doing its job.
Providers that only implement the older integer hook, detect_usage_limit, still work: their
limits arrive with kind="unknown" and timing_source="estimated".
Persistence and resuming¶
Pass data_dir= to persist a session to disk, and grab a resume_handle to continue it later
(even in another process). Those are covered in Resuming sessions and
Persistence.