v0.1.0GitHubnpm
Concepts

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.

run.ts
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.

Session.run(prompt: string, opts?: RunOptions): AsyncIterable<Event>
gen

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.

signalAbortSignal?Optional

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.

maxOutputTokensnumber?Optional

Per-run override of the provider’s default output cap. Useful when one turn needs more headroom than the agent’s default.

temperaturenumber?Optional

Per-run temperature override.

imagesArray<{ data; mediaType } | { url }>?Optional

Images attached to the user message. Inline base64 (data + mediaType) or remote URL. Multiple entries are joined to the prompt in order.

thinkingThinkingConfig?Optional

Per-run extended-thinking config — Anthropic-only. Overrides the provider default for this run and is propagated to any subagents spawned within the turn.

effortEffortLevel?Optional

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:

event-loop.ts
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()

Session.abort(reason?: unknown): void
method

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:

FieldTypeNotes
idstringStable 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.
createdAtDateParsed from the stored ISO-8601 timestamp.
metaRecord<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.
messageCountnumber (getter)Count of messages currently visible to the provider — i.e. the post-compaction view, not the full history.
Session.updateMeta(patch: Record<string, unknown>): Promise<void>
async

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.

sessions.ts
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. Both providerView and fullHistory start identical — compaction divergence only happens within the current process.
  • Invoked skills. The ordered invokedSkills list is read off the session record and re-emitted as a skill_invoked event 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 meta object is loaded. Supplying meta alongside an existing id has no effect — use session.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.

store.ts
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>;
}
MethodContract
createIdempotent 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 / listload returns undefined on miss, never throws. list sorts by updated_at descending; limit and offset are optional.
appendMessagesAtomic — 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.
loadMessagesAlways returns messages sorted by seq ascending.
updateMetaShallow merge into the existing meta object — caller-supplied keys overwrite existing ones, others are preserved. Bumps updated_at.
setInvokedSkillsOverwrites the full ordered list every call — callers always pass the complete array, never a delta.
deleteCascades 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.
updateTaskAccepts 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.
closeOptional. 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

StoreBest for
SqliteSessionStoreDefault. Persists to .skawld/sessions.db resolved relative to cwd. Single-process apps, CLIs, local agents.
InMemorySessionStoreTests, 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.

new SqliteSessionStore(opts?: SqliteSessionStoreOptions)
constructor
databasePathstring?Optional

Absolute path to the SQLite file. Defaults to <cwd>/.skawld/sessions.db. The parent directory is created with mkdirSync(..., { recursive: true }) if missing.

cwdstring?Optional

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.

memory-store.ts
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.

my-store.ts
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 sessionStore to the agent, the default SqliteSessionStore is only instantiated on the first agent.session() call. An agent that is created and then never used to start a session does not touch disk and does not need close().
  • agent.close() closes the store. It calls store.close?.() only if the store was actually allocated. For SqliteSessionStore that closes the SQLite handle; for InMemorySessionStore it is a no-op (no close defined).
  • One agent, many sessions. Reusing one Agent across many session ids is the intended pattern. Each Session instance 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.