Core concepts
Skawld factors an agent into five long-lived objects and one async generator. The objects own configuration; the generator — the run loop — owns time. This page is the map: what each object holds, what it does not, how one turn flows end to end, and where the concurrency boundaries sit.
#The five objects
Everything you import from @skawld/agent-sdk hangs off one of five things. They split cleanly into per-process and per-conversation state — see lifecycles below for why.
#Agent
The top-level handle. Constructed once per process (typically). Owns the provider instance, the tool registry, the permission engine, the session store, MCP connections, loaded skills, and the subagent registry. Holds knobs that apply to every run: maxRetries, maxOutputTokens, maxTurns, cacheTtl, the compaction strategy, the working directory, and the assembled system-prompt blocks. Does not own message history or run state — those live on the Session. MCP servers, skills, and subagents all connect lazily on the first session() call and are memoized for the Agent’s lifetime. See Agent.
#Session
One conversation. Owns the message history (both the provider view sent on the wire and the full pre-compaction history kept for replay/export), the file-read tracker, the per-run abort controller, and the cumulative usage from the last turn. A Session is bound to its Agent for its lifetime and reads provider, tools, and permissions through it. The Session does not own the provider, tools, permissions, or system prompt — those are Agent state. Calling agent.session() creates or resumes one; calling session.run(prompt) returns an async iterable of events and is the only way time advances. See Sessions.
#Provider
A streaming adapter for a model API. The loop hands the provider a ProviderRequest (messages, system blocks, tool schemas, max-output, temperature, cache hints, signal) and consumes a single async iterable of normalized stream events (text_delta, thinking_delta, tool_use_start, tool_use_input_delta, tool_use_end, message_end). Providers also map provider-specific errors to skawld’s typed errors (RateLimitError, ContextLengthError, etc.) and apply the skawld-managed retry budget on retryable failures. See Providers.
#ToolRegistry
A name-keyed bag of tools. Each tool declares JSON-schema input validation, a summarize() for event display, an execute() that returns content + an is_error flag, and a parallelSafe bit that the scheduler reads to batch calls. MCP tools and the built-in Skill / Subagent tools register into the same registry. A Session can carry a toolsOverride (used by the subagent runner to give a child a filtered view), so the registry the model sees is technically session-scoped even though the instance is shared. See Tools and MCP.
#PermissionEngine
Decides whether each tool call runs. Three inputs: mode (default, acceptEdits, yolo), a list of static rules evaluated in first-match-wins order, and an optional canUseTool callback consulted only when rules + mode produce ask. The engine’s synchronous evaluate() is called by the scheduler before any tool starts; only ask-bound calls hit the async resolve() and invoke canUseTool. An allow decision can return updatedInput, which is re-validated through the tool’s schema before becoming the canonical input reported in tool_call_start. See Permissions.
#The loop
One session.run() call is one async generator — runLoop in core/loop.ts. Every event the consumer sees is yielded by it in the order below. The cycle is: stream the provider, assemble one assistant message, schedule the tools it called, append the aggregated tool_result message, repeat until the model stops calling tools — or until the run aborts, errors, or hits maxTurns.
#Anatomy of one turn
Pre-flight. On the very first turn,
runLoopyields aSystemEvent(subtypeinit) with the resolved model, tool names, permission mode, cwd, and a freshrun_id. If skills are loaded and have not yet been announced for this Session, aSkillsLoadedEventfollows. Then the user prompt is wrapped (env prefix + prompt + any images, plus askill_listingsystem-reminder on a brand-new session), appended to the store, and yielded as aUserEvent.Abort gate. Each turn begins by checking the combined abort signal (the session’s internal controller chained with the caller’s
opts.signal) and throwingAbortErrorif either fired.Compaction check.
maybeCompact()projects total tokens against the provider’s context window and, if usage crosses the default 80% threshold, summarizes older history into the providerView. ACompactionEventis yielded; the full history is kept intact infullHistoryand in the store. After compaction the skill listing and any previously-invoked skill bodies are re-injected so skill context survives.Build the request.
buildRequestassembles aProviderRequestfrom the session’s providerView, the agent’s system blocks (or the session override, for subagents), the registry’s tool schemas, the effective max-output, the temperature, the cache TTL, and the chained abort signal.Stream the provider.
streamTurnconsumesprovider.stream(req)and incrementally assembles content blocks: text/thinking deltas into buffers, tool_use deltas into a per-id JSON buffer. WhenincludePartialMessagesis true, each delta also yields aPartialAssistantEvent. The provider’smessage_endcloses out with a finalstop_reasonandusage.Context-length retry. If the stream throws
ContextLengthErrormid-turn,streamTurnWithContextRetryruns a forced compaction once, yields aCompactionEvent, and replays the request. One retry per turn — the flag clears at the top of the next turn.Emit the assistant message. The assembled message is appended to the store and yielded as an
AssistantEvent. AUsageEventfollows with the turn’s usage and the cumulative total.Stop or fan out. If
stop_reasonis anything other thantool_use(end_turn,max_tokens,stop_sequence,refusal), the loop yields aResultEventwith subtypesuccessand returns. Otherwise the assistant’stool_useblocks are handed toexecuteToolCalls.Resolve tools and permissions. The scheduler resolves each block against the effective registry, validates input through the tool’s schema, then runs a first synchronous pass through the permission engine. Calls that resolve to
askare collected; a singlePermissionRequestEventis yielded for the batch beforecanUseToolis invoked sequentially for each ask.Partition into batches. Resolved calls are walked in order; adjacent
parallelSafecalls go into one parallel batch, every other call gets its own serial batch. The model’s call order is preserved.Execute each batch. For every call:
tool_call_startis emitted first (with the effective input — post-updatedInputrewrite), then the tool runs with a per-callctx.emitthat can stream interim events (skills emitskill_invoked; subagents emitsubagent_eventwrappers), thentool_call_endcloses the bracket. Parallel batches merge per-call generators and interleave by completion order; consumers demultiplex bytool_use_id.Aggregate and continue. All
tool_resultblocks are reassembled in original block order, packed into one user message, appended to the store, yielded as aUserEvent, and the loop returns to step 2 for the next turn.
Abort is cooperative and bracket-safe.
session.abort() aborts the per-run controller; the chained
signal trips on the next abort gate or any in-flight provider/tool call
that honors signals. The scheduler synthesizes a
tool_call_end for every tool that emitted a start but was
abandoned mid-parallel-batch, so consumers tracking in-flight tools never
leak entries. The final event is a ResultEvent with subtype
aborted.
#Event flow for one turn (with two parallel tools)
Below is the typical event order for a fresh session, one assistant turn that calls two parallelSafe tools (e.g. two Reads), and one more turn that ends cleanly. partial_assistant is omitted (opt-in via includePartialMessages).
system { run_id, model, tools, permission_mode, cwd }
skills_loaded { skills: [...] } # only if skills exist, once per session
user { message: env + prompt }
# turn 1
assistant { message: [text, tool_use, tool_use], stop_reason: "tool_use" }
usage { usage, cumulative }
tool_call_start { tool_use_id: A, tool_name, input }
tool_call_start { tool_use_id: B, tool_name, input } # interleaved with A
tool_call_end { tool_use_id: A, is_error, duration_ms }
tool_call_end { tool_use_id: B, is_error, duration_ms }
user { message: [tool_result A, tool_result B] }
# turn 2
assistant { message: [text], stop_reason: "end_turn" }
usage { usage, cumulative }
result { subtype: "success", stop_reason, total_usage, duration_ms, final_text }If any call resolves to ask, a single permission_request event precedes the first tool_call_start of the turn. If compaction fires at the top of a turn, a compaction event precedes the next assistant. The full event union is documented in Events.
#Lifecycles: Agent vs Session
The split is deliberate. Anything expensive to construct or shared across conversations lives on the Agent. Anything that mutates while a run is in flight lives on the Session.
| State | Owner | Why |
|---|---|---|
| provider, model, system blocks | Agent | Constructed once; reused across runs. |
| tool registry, MCP connections | Agent | MCP servers are long-lived stdio/http processes; sharing them across sessions amortizes the spawn cost. |
| permission rules, mode, canUseTool | Agent | Policy is process-wide, not per-conversation. |
| cwd, configDir, skills, subagent registry | Agent | Loaded once from <configDir>/skills/ and <configDir>/agents/ on first session(). |
| SessionStore (default SQLite at .skawld/sessions.db) | Agent | Single connection per process; sessions are rows in it. Lazily opened on first session(). |
| providerView + fullHistory | Session | providerView is what goes on the wire; fullHistory keeps the pre-compaction record for replay and export. |
| file-read tracker, invoked skills, run_id | Session | Per-conversation context the loop and tools mutate as they run. |
| activeRunId, abort controller | Session | A fresh AbortController is allocated per run() so a prior abort never pre-poisons the next run. |
#Concurrency model
Three layers of concurrency, each with a defined boundary.
#Many sessions per Agent
One Agent can back any number of Sessions concurrently. They share the provider, tools, MCP connections, permission engine, and session store. The store and the MCP client are designed for concurrent use; the permission engine’s synchronous evaluate() is pure and reentrant. There is no agent-wide lock — independent Sessions run in parallel without coordination.
#One run per Session
A Session can only have one active run at a time. Calling run() while another is active throws ConfigError("Session already has an active run") synchronously. If you need to fan out, create more Sessions or spawn a subagent — the subagent runner creates a child Session with its own controller and merges the child’s events into the parent stream as subagent_event wrappers.
#Tools within a turn
Within a single assistant turn, the scheduler partitions the model’s tool_use blocks into adjacent batches by parallelSafe. Parallel batches run concurrently up to SKAWLD_MAX_TOOL_CONCURRENCY (default 10); non-parallel calls and unknown tools each form a serial batch of one. The partitioning preserves the model’s call order: [Read, Read, Bash, Read] becomes three batches — par(Read, Read), ser(Bash), par(Read). Tool results are reassembled in the original block order before being sent back to the model.
Parallel events interleave; demultiplex by tool_use_id.
Within a parallel batch, tool_call_start /
tool_call_end events from different tools interleave in
completion order. The per-tool ordering (start → emitted
events → end) is preserved per tool_use_id, but
you cannot assume a flat sequence across tools. The same applies to
tool-emitted skill_invoked / subagent_event
brackets.
#Pick a thread
- Agent — constructor, options, MCP and skill connect, close().
- Sessions — running, resuming, aborting, persisted state.
- Events — the full closed event union and guards.
- Providers — Anthropic, OpenAI, the
BaseProvidercontract, error mapping. - Tools — built-ins,
parallelSafe, writing your own. - MCP — stdio + http servers,
mcp__<server>__<tool>naming. - Permissions — rules, modes,
canUseTool, input rewriting. - Compaction — strategy contract, the 80% threshold, post-compaction skill replay.
- Skills — disk-loaded prompt fragments, one-turn overlays.
- Subagents — specialist child sessions, event nesting.