v0.1.0GitHubnpm
Concepts

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

  1. Pre-flight. On the very first turn, runLoop yields a SystemEvent (subtype init) with the resolved model, tool names, permission mode, cwd, and a fresh run_id. If skills are loaded and have not yet been announced for this Session, a SkillsLoadedEvent follows. Then the user prompt is wrapped (env prefix + prompt + any images, plus a skill_listing system-reminder on a brand-new session), appended to the store, and yielded as a UserEvent.

  2. Abort gate. Each turn begins by checking the combined abort signal (the session’s internal controller chained with the caller’s opts.signal) and throwing AbortError if either fired.

  3. 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. A CompactionEvent is yielded; the full history is kept intact in fullHistory and in the store. After compaction the skill listing and any previously-invoked skill bodies are re-injected so skill context survives.

  4. Build the request. buildRequest assembles a ProviderRequest from 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.

  5. Stream the provider. streamTurn consumes provider.stream(req) and incrementally assembles content blocks: text/thinking deltas into buffers, tool_use deltas into a per-id JSON buffer. When includePartialMessages is true, each delta also yields a PartialAssistantEvent. The provider’s message_end closes out with a final stop_reason and usage.

  6. Context-length retry. If the stream throws ContextLengthError mid-turn, streamTurnWithContextRetry runs a forced compaction once, yields a CompactionEvent, and replays the request. One retry per turn — the flag clears at the top of the next turn.

  7. Emit the assistant message. The assembled message is appended to the store and yielded as an AssistantEvent. A UsageEvent follows with the turn’s usage and the cumulative total.

  8. Stop or fan out. If stop_reason is anything other than tool_use (end_turn, max_tokens, stop_sequence, refusal), the loop yields a ResultEvent with subtype success and returns. Otherwise the assistant’s tool_use blocks are handed to executeToolCalls.

  9. 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 ask are collected; a single PermissionRequestEvent is yielded for the batch before canUseTool is invoked sequentially for each ask.

  10. Partition into batches. Resolved calls are walked in order; adjacent parallelSafe calls go into one parallel batch, every other call gets its own serial batch. The model’s call order is preserved.

  11. Execute each batch. For every call: tool_call_start is emitted first (with the effective input — post-updatedInput rewrite), then the tool runs with a per-call ctx.emit that can stream interim events (skills emit skill_invoked; subagents emit subagent_event wrappers), then tool_call_end closes the bracket. Parallel batches merge per-call generators and interleave by completion order; consumers demultiplex by tool_use_id.

  12. Aggregate and continue. All tool_result blocks are reassembled in original block order, packed into one user message, appended to the store, yielded as a UserEvent, 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).

text
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.

StateOwnerWhy
provider, model, system blocksAgentConstructed once; reused across runs.
tool registry, MCP connectionsAgentMCP servers are long-lived stdio/http processes; sharing them across sessions amortizes the spawn cost.
permission rules, mode, canUseToolAgentPolicy is process-wide, not per-conversation.
cwd, configDir, skills, subagent registryAgentLoaded once from <configDir>/skills/ and <configDir>/agents/ on first session().
SessionStore (default SQLite at .skawld/sessions.db)AgentSingle connection per process; sessions are rows in it. Lazily opened on first session().
providerView + fullHistorySessionproviderView is what goes on the wire; fullHistory keeps the pre-compaction record for replay and export.
file-read tracker, invoked skills, run_idSessionPer-conversation context the loop and tools mutate as they run.
activeRunId, abort controllerSessionA 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 BaseProvider contract, 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.