Sessions
A session is one conversation: a stable id, an append-only message log, an in-memory provider view, and any persistent tasks. Sessions are persisted through a SessionStore — by default a SQLite file at .skawld/sessions.db — so an interrupted run can be resumed verbatim by id.
const session = await agent.session(); for await (const event of session.run("Summarize CHANGELOG.md")) { switch (event.type) { case "assistant": // streamed text + tool_use blocks from the model break; case "tool_call_start": console.log("→ calling", event.tool_name); break; case "tool_call_end": console.log("✓", event.tool_name, event.ok ? "ok" : "failed"); break; case "result": console.log("done in", event.elapsed_ms, "ms"); return; } }
#session.run()
A session is created with agent.session(). Each call to session.run(prompt, opts?) kicks off a single turn of the agent loop and returns an async iterator of events. The iterator runs until the loop terminates (a result event with subtype "success", "aborted", or "error") or the consumer breaks out of the for-await loop.
prompt is treated as the new user message — the loop appends it to the session history, persists it, then drives the provider. To attach images, use opts.images; the prompt itself is always a plain string. Throws ConfigError synchronously if a run is already active on this session.
Caller-supplied AbortSignal. Chained with the session’s
internal controller, so either side can cancel. The internal controller is
replaced with a fresh one at the start of every run(), so
aborts between runs never poison the next one.
Per-run override of the provider’s default output cap. Useful when one turn needs more headroom than the agent’s default.
Per-run temperature override.
Images attached to the user message. Inline base64 (data +
mediaType) or remote URL. Multiple entries are joined to the
prompt in order.
Per-run extended-thinking config — Anthropic-only. Overrides the provider default for this run and is propagated to any subagents spawned within the turn.
Per-run reasoning-effort hint — Anthropic-only. Same propagation rules as
thinking.
There is no separate runStream(). run()
is the streaming surface — events are yielded as they come off
the provider. To collect a final answer, drain the iterator and read the
final_text on the terminating result event.
#Handling the event stream
The iterator yields one of the event types in the Event union. A typical loop handles these explicitly and lets the rest fall through:
for await (const event of session.run(prompt, { signal })) { switch (event.type) { case "system": // First event of every run: run_id, model, tools, permission_mode, cwd break; case "partial_assistant": // Streamed deltas: { kind: "text" | "thinking" | "tool_use_input", ... } if (event.delta.kind === "text") process.stdout.write(event.delta.text); break; case "assistant": // Complete assistant message (text + tool_use blocks) committed to history break; case "tool_call_start": console.log("→", event.tool_name, event.input); break; case "tool_call_end": console.log(event.is_error ? "✗" : "✓", event.tool_name, event.duration_ms, "ms"); break; case "permission_request": // canUseTool is being awaited; UI can mirror the request queue here break; case "usage": // Per-turn and cumulative token counts break; case "compaction": console.log("compacted", event.messages_before, "→", event.messages_after); break; case "result": // subtype: "success" | "aborted" | "error" console.log("done:", event.subtype, "in", event.duration_ms, "ms"); return; case "error": console.error(event.error.name, event.error.message); break; } }
Breaking out of the for-await loop, calling .return() on the iterator, or letting it be garbage-collected all trigger an internal cleanup hook that clears the session’s active run state. It is therefore safe to abandon a run mid-stream — the next call to session.run() will succeed on a fresh controller.
#session.abort()
Cancel the in-flight run. Idempotent. The next event yielded by the active iterator will be a result with subtype "aborted". Calling abort() while no run is active is a safe no-op — it aborts the idle placeholder controller, which is discarded the next time run() is called.
#Session properties
Most session state is internal and reached only through run(), but a few fields are public and stable across resume:
| Field | Type | Notes |
|---|---|---|
id | string | Stable identifier. Assigned via crypto.randomUUID() if not supplied to agent.session(). The id is the only key needed to resume; everything else (messages, tasks, invoked skills) is loaded from the store. |
createdAt | Date | Parsed from the stored ISO-8601 timestamp. |
meta | Record<string, unknown> | Free-form metadata you pass to agent.session() (project root, title, tags, user id, etc.). Mutated in place by updateMeta() via shallow merge. |
messageCount | number (getter) | Count of messages currently visible to the provider — i.e. the post-compaction view, not the full history. |
Shallow-merge an update into the session’s metadata. Persists through the store and mutates the in-memory meta object in place.
#Resuming a session
Pass an id to agent.session() to resume a prior conversation. The agent calls store.create({ id, meta }), which is idempotent — if the id already exists the stored record is returned and your meta is ignored. If the id is new, a fresh row is inserted with that id. There is no "not found" error path: an unknown id silently becomes a new session with that id.
import { Agent } from "@skawld/agent-sdk"; import { InMemorySessionStore } from "@skawld/agent-sdk/sessions"; const agent = new Agent({ provider, model: "gpt-5", sessionStore: new InMemorySessionStore(), }); // resume an existing conversation by id const session = await agent.session({ id: "ops-help-2026-05-28" });
What is restored on resume:
- Full message history. All messages in seq order are loaded via
store.loadMessages(id)and used as the provider view. BothproviderViewandfullHistorystart identical — compaction divergence only happens within the current process. - Invoked skills. The ordered
invokedSkillslist is read off the session record and re-emitted as askill_invokedevent sequence at the start of the next run. - Tasks and task edges. Tasks created by the task tools survive across processes; resume sees the same task ids and dependency graph the previous run wrote.
- Metadata. The original
metaobject is loaded. Supplyingmetaalongside an existing id has no effect — usesession.updateMeta()to change it.
Per-run mutable state is not restored:
FileReadTracker, the active AbortController,
and any in-process subagent counters all start fresh. Resume gives you
the conversation back, not the runtime.
#Session stores
A SessionStore is the persistence boundary for sessions, messages, invoked skills, and tasks. The SDK ships two — implement the interface yourself to back sessions with Postgres, Redis, or any remote KV.
#SessionStore interface
Every method is async. Implementations are expected to assign monotonic seq values to messages and to bump updated_at on every write. Both bundled stores wrap multi-statement writes in a transaction; a custom store should match that behavior or accept the risk of partially-applied appends.
interface SessionStore { // ----- sessions ----- create(record: { id?: string; meta?: Record<string, unknown> }): Promise<SessionRecord>; load(id: string): Promise<SessionRecord | undefined>; list(opts?: { limit?: number; offset?: number }): Promise<SessionRecord[]>; updateMeta(id: string, meta: Record<string, unknown>): Promise<SessionRecord>; delete(id: string): Promise<void>; // ----- messages ----- loadMessages(id: string): Promise<StoredMessage[]>; appendMessages(id: string, messages: Message[]): Promise<StoredMessage[]>; // ----- invoked skills ----- setInvokedSkills(id: string, skills: InvokedSkillRecord[]): Promise<void>; // ----- tasks ----- createTask(sessionId: string, input: CreateTaskInput): Promise<Task>; getTask(sessionId: string, taskId: string): Promise<Task | undefined>; listTasks(sessionId: string): Promise<Task[]>; updateTask(sessionId: string, taskId: string, patch: TaskPatch): Promise<Task | undefined>; deleteTask(sessionId: string, taskId: string): Promise<boolean>; // ----- lifecycle ----- close?(): Promise<void>; }
| Method | Contract |
|---|---|
create | Idempotent on id. If a record with the same id exists, return it unchanged — do not overwrite meta. If id is omitted, mint a fresh one (both built-ins use crypto.randomUUID()). |
load / list | load returns undefined on miss, never throws. list sorts by updated_at descending; limit and offset are optional. |
appendMessages | Atomic — all messages in the call must commit together with a single updated_at bump. Implementations MUST assign strictly monotonic seq values starting at 1, and ordering across concurrent appends to the same session id is undefined unless the store serializes them. |
loadMessages | Always returns messages sorted by seq ascending. |
updateMeta | Shallow merge into the existing meta object — caller-supplied keys overwrite existing ones, others are preserved. Bumps updated_at. |
setInvokedSkills | Overwrites the full ordered list every call — callers always pass the complete array, never a delta. |
delete | Cascades to messages, tasks, task edges, and task counters for that session id. The SQLite store relies on ON DELETE CASCADE for this; the in-memory store walks its tables explicitly. |
updateTask | Accepts a TaskPatch. A status of "deleted" removes the task and all its edges and returns undefined. Edge additions are validated for cycles — adding a dependency that would create a cycle throws Error("Dependency cycle detected"). Metadata patches are shallow-merged; a null value deletes that key. |
close | Optional. Only relevant for stores with persistent handles (database connections, file descriptors). Called from agent.close() exactly once, and only if the store was actually instantiated. |
The store interface assumes a
single writer per session id. Two processes appending
to the same session id through the same store will produce well-ordered
seqs only if the underlying engine serializes the write (SQLite’s
transaction does; InMemorySessionStore within one process
does). Across processes against the same SQLite file, expect
SQLite’s usual SQLITE_BUSY behavior — there is no
application-level retry.
#Built-in stores
| Store | Best for |
|---|---|
SqliteSessionStore | Default. Persists to .skawld/sessions.db resolved relative to cwd. Single-process apps, CLIs, local agents. |
InMemorySessionStore | Tests, ephemeral scripts. State lives in Maps on the instance and is gone when the process exits. |
#SqliteSessionStore
The default store. Constructed lazily on the first agent.session() call — instantiating an Agent never touches disk on its own. Both Bun and Node are supported; the constructor picks bun:sqlite under Bun and better-sqlite3 under Node at module load time.
Absolute path to the SQLite file. Defaults to
<cwd>/.skawld/sessions.db. The parent directory is
created with mkdirSync(..., { recursive: true }) if
missing.
Working directory used to derive the default path. Defaults to
process.cwd(). Ignored when databasePath is
supplied.
The schema (sessions, messages, invoked_skills_json, tasks, task_edges, task_counters) is created on first open by executing a single inline DDL block — there is no migration system, no version table, and no schema-mutation surface. Schema changes between SDK versions are handled by the SDK; the store does not negotiate them.
Concurrency: every multi-statement write (appendMessages, createTask, updateTask) runs inside db.transaction(). Within one process the driver serializes; across processes you get SQLite’s file-lock semantics. close() closes the underlying database handle.
#InMemorySessionStore
Pure in-process state — five Maps, one constructor() with no arguments. Implements the same cycle-detection on task edges as the SQLite store, so tests exercising the task graph behave identically.
import { Agent } from "@skawld/agent-sdk"; import { InMemorySessionStore } from "@skawld/agent-sdk/sessions"; const agent = new Agent({ provider, model: "claude-opus-4-5", sessionStore: new InMemorySessionStore(), });
#Writing a custom store
Implement SessionStore end to end — there are no optional methods other than close. The skeleton below shows the full surface; wire each stub to your backend (Postgres rows, Redis hashes, a REST KV, whatever). The types come from @skawld/agent-sdk/sessions.
import type { SessionStore, SessionRecord, StoredMessage, Task, TaskPatch, CreateTaskInput, } from "@skawld/agent-sdk/sessions"; import type { Message, InvokedSkillRecord } from "@skawld/agent-sdk"; export class MyStore implements SessionStore { constructor(private readonly db: MyBackend) {} // ----- sessions ----- async create(r: { id?: string; meta?: Record<string, unknown> }): Promise<SessionRecord> { /* … */ } async load(id: string): Promise<SessionRecord | undefined> { /* … */ } async list(opts?: { limit?: number; offset?: number }): Promise<SessionRecord[]> { /* … */ } async updateMeta(id: string, meta: Record<string, unknown>): Promise<SessionRecord> { /* … */ } async delete(id: string): Promise<void> { /* cascade to messages + tasks + edges */ } // ----- messages ----- async loadMessages(id: string): Promise<StoredMessage[]> { /* ORDER BY seq ASC */ } async appendMessages(id: string, messages: Message[]): Promise<StoredMessage[]> { // assign monotonic seq starting at MAX(seq)+1, bump updated_at, atomically } // ----- invoked skills ----- async setInvokedSkills(id: string, skills: InvokedSkillRecord[]): Promise<void> { /* overwrite */ } // ----- tasks ----- async createTask(sid: string, input: CreateTaskInput): Promise<Task> { /* … */ } async getTask(sid: string, taskId: string): Promise<Task | undefined> { /* … */ } async listTasks(sid: string): Promise<Task[]> { /* ORDER BY CAST(id AS INTEGER) */ } async updateTask(sid: string, taskId: string, patch: TaskPatch): Promise<Task | undefined> { // honor "deleted" status, validate cycles on add_blocks/add_blocked_by } async deleteTask(sid: string, taskId: string): Promise<boolean> { /* + cascade edges */ } // ----- lifecycle ----- async close(): Promise<void> { await this.db.close(); } } const agent = new Agent({ provider, model: "claude-opus-4-5", sessionStore: new MyStore(db) });
configDir is unrelated to the session store. The
agent’s configDir option (see
Configuration) controls where
skills and subagents are loaded from on disk. The session store lives
wherever you point SqliteSessionStore (or your custom
store) and is configured separately via the agent’s
sessionStore option.
#Lifecycle and cleanup
The store is owned by the agent. Three rules cover everything:
- Lazy construction. If you do not pass
sessionStoreto the agent, the defaultSqliteSessionStoreis only instantiated on the firstagent.session()call. An agent that is created and then never used to start a session does not touch disk and does not needclose(). agent.close()closes the store. It callsstore.close?.()only if the store was actually allocated. ForSqliteSessionStorethat closes the SQLite handle; forInMemorySessionStoreit is a no-op (noclosedefined).- One agent, many sessions. Reusing one
Agentacross many session ids is the intended pattern. EachSessioninstance has its own append history, tracker state, and abort controller — they are isolated. The shared surface is the underlying store and the agent’s tools, model, permissions, and skills.
A given Session instance does not allow concurrent runs.
Calling session.run() while a prior iterator is still
active throws ConfigError synchronously. To run two
conversations in parallel, get two sessions —
await Promise.all([agent.session(), agent.session()]) — not
one session in two places.
#Tasks and todo persistence
The task tools (TaskCreate, TaskList, TaskGet, TaskUpdate) write through the same store the session uses. A task is a tuple of id (numeric string, monotonically assigned per session starting at "1"), subject, description, optional active_form and owner, status ("pending", "in_progress", "completed"), free-form metadata, and two arrays of task ids: blocks and blocked_by.
Tasks survive resume. When you reopen a session by id, calling TaskList returns the same ordered set the previous run wrote, with the same numeric ids, the same dependency edges, and the same status. The dependency graph is validated for cycles on every edge addition — adding a blocks or blocked_by edge that would form a cycle throws "Dependency cycle detected" and leaves the graph unchanged.
Deleting a task (either via deleteTask or by patching status to "deleted" through updateTask) removes the row and every edge touching it. Deleting a session via store.delete(id) cascades to its tasks, edges, and counter.