Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Architecture of a coding agent

This chapter steps back from individual crates and looks at how they compose into a complete coding agent — the kind of tool you use when you use Claude Code or Codex CLI.

The previous chapters covered each crate in isolation. This chapter shows how they fit together. The goal is not to document every API — that’s what the earlier chapters did. The goal is to show the composition pattern and the trade-offs involved.

What a coding agent needs

A production coding agent requires all of the pieces we’ve covered:

Concernagentkit crate
Transcript and data modelagentkit-core
Capability abstractionagentkit-capabilities
Agent loop and driveragentkit-loop
Tool registry and executionagentkit-tools-core
File read/write/editagentkit-tool-fs
Shell command executionagentkit-tool-shell
Project context loadingagentkit-context
Transcript managementagentkit-compaction
Async task schedulingagentkit-task-manager
Event reportingagentkit-reporting
LLM provider adapteragentkit-provider-openrouter

Plus host-specific concerns:

  • CLI argument parsing and input handling
  • Terminal rendering and streaming output
  • Permission policy configuration
  • Error recovery and retry strategy
  • Session management

The composition pattern

#![allow(unused)]
fn main() {
// 1. Configure tools
let tools = agentkit_tool_fs::registry()
    .merge(agentkit_tool_shell::registry());

// 2. Configure permissions
let permissions = CompositePermissionChecker::new(PermissionDecision::Deny(default_denial()))
    .with_policy(PathPolicy::new().allow_root(workspace_root))
    .with_policy(CommandPolicy::new().require_approval_for_unknown(true));

// 3. Configure compaction
let compaction = CompactionConfig::new(
    ItemCountTrigger::new(20),
    CompactionPipeline::new()
        .with_strategy(DropReasoningStrategy::new())
        .with_strategy(KeepRecentStrategy::new(12)
            .preserve_kind(ItemKind::System)
            .preserve_kind(ItemKind::Context)),
);

// 4. Configure task management
let task_manager = AsyncTaskManager::new().routing(|req: &ToolRequest| {
    if req.tool_name.0 == "shell.exec" {
        RoutingDecision::ForegroundThenDetachAfter(Duration::from_secs(10))
    } else {
        RoutingDecision::Foreground
    }
});

// 5. Configure reporting
let reporter = CompositeReporter::new()
    .with_observer(StdoutReporter::new(std::io::stderr()))
    .with_observer(UsageReporter::new());

// 6. Load context
let context_items = ContextLoader::new()
    .with_source(AgentsMd::discover_all(workspace_root))
    .load()
    .await?;

// 7. Assemble the agent
let agent = Agent::builder()
    .model(OpenRouterAdapter::new(OpenRouterConfig::new(api_key, model))?)
    .tools(tools)
    .permissions(permissions)
    .compaction(compaction)
    .task_manager(task_manager)
    .observer(reporter)
    .build()?;
}

The host loop

The host application drives the interaction:

#![allow(unused)]
fn main() {
let mut driver = agent.start(session_config).await?;

// Submit system prompt and context
driver.submit_input(system_items)?;
driver.submit_input(context_items)?;

loop {
    // Get user input
    let user_input = read_line()?;
    driver.submit_input(vec![user_item(user_input)])?;

    // Run the agent turn
    loop {
        match driver.next().await? {
            LoopStep::Interrupt(LoopInterrupt::ApprovalRequest(req)) => {
                let decision = prompt_user_approval(&req)?;
                driver.resolve_approval(decision)?;
            }
            LoopStep::Interrupt(LoopInterrupt::AuthRequest(req)) => {
                let resolution = handle_auth(&req)?;
                driver.resolve_auth(resolution)?;
            }
            LoopStep::Interrupt(LoopInterrupt::AwaitingInput(_)) => break,
            LoopStep::Finished(result) => {
                print_usage(&result);
                break;
            }
        }
    }
}
}

Crate dependency graph

agentkit-core                    (no dependencies)
     │
     ├── agentkit-capabilities
     │        │
     │        ├── agentkit-tools-core
     │        │        │
     │        │        ├── agentkit-tool-fs
     │        │        ├── agentkit-tool-shell
     │        │        └── agentkit-tool-skills
     │        │
     │        └── agentkit-mcp
     │
     ├── agentkit-compaction
     │
     ├── agentkit-context
     │
     ├── agentkit-task-manager
     │
     ├── agentkit-reporting
     │
     ├── agentkit-adapter-completions
     │        │
     │        ├── agentkit-provider-openrouter
     │        ├── agentkit-provider-openai
     │        ├── agentkit-provider-ollama
     │        ├── agentkit-provider-vllm
     │        ├── agentkit-provider-groq
     │        └── agentkit-provider-mistral
     │
     └── agentkit-loop          (coordinates everything)
              │
              └── agentkit      (re-exports for convenience)

Every crate depends on agentkit-core. The loop crate depends on tools, compaction, and task management. Provider crates depend on the completions adapter. Everything else is a leaf.

Design trade-offs

Sequential vs parallel tool execution

The default SimpleTaskManager is sequential. For a coding agent, this is often fine — file operations are fast and order matters. Shell commands are the exception: builds and tests can take seconds or minutes. ForegroundThenDetachAfter gives you the best of both worlds.

Tool typeRecommended routingWhy
Filesystem toolsForegroundFast, order-sensitive
Shell toolsForegroundThenDetachAfter(5-10s)May be fast or slow
MCP toolsForegroundUsually fast

Compaction strategy

Aggressive compaction loses context. Conservative compaction hits the context window. The right balance depends on the model’s context size and the nature of the work.

Recommended starting point:

  Trigger: 20 items
  Pipeline:
    1. DropReasoningStrategy         (reasoning blocks are verbose, rarely needed later)
    2. DropFailedToolResultsStrategy (failed tool results add noise)
    3. KeepRecentStrategy(12)        (keep last 12 non-preserved items)
       .preserve_kind(System)        (system prompt is always needed)
       .preserve_kind(Context)       (project context is always needed)

For coding agents, keeping recent tool interactions is usually more valuable than keeping old conversation text — the model needs to know what it just read and edited, not what the user said 20 turns ago.

Permission posture

Default-deny is safest but requires more approval prompts. Default-allow with denylists is more fluid but riskier. Most coding agents land in the middle:

Recommended permission posture:

ScopeDecision
Filesystem readsAllow within workspace
Filesystem writesAllow within workspace (with read-before-write)
Filesystem outsideRequireApproval
Protected files (.env)Deny
Shell (known safe)Allow (git, cargo, npm, ls, etc.)
Shell (unknown)RequireApproval
Shell (dangerous)Deny (rm, dd, mkfs)
MCP (trusted)Allow
MCP (unknown)RequireApproval
FallbackDeny

What the host owns

agentkit handles the loop, tools, permissions, and streaming. The host application owns everything else:

  • Input/output — how users type messages and see results
  • Session lifecycle — when sessions start, end, and resume
  • Error recovery — what to do when the model fails or rate-limits
  • Configuration — which model, which tools, which policies
  • Persistence — saving transcripts, session state, usage logs

The boundary is intentional: agentkit is a library, not a framework. The host is in control.

Example: openrouter-agent-cli is the closest existing example to a full coding agent — it combines context, tools, shell, MCP, compaction, and reporting.