Imported from BrendanGraham14/steer (
AGENTS.md). Install upstream withnpx skills add BrendanGraham14/steer. Copyright stays with the author.
Dev Environment
This repo includes flake.nix. Run commands directly by default. Only use the Nix devshell when a command fails due to missing dependencies that are expected to be provided by the devshell (for example, nix develop -c <command>).
Commands
Run just commands directly first. If one fails due to missing deps, retry it inside nix develop.
# Default
just check
just test
just build
# Fallback when deps are missing
nix develop --command just check
nix develop --command just test
nix develop --command just build
# For multiple commands (fallback)
nix develop --command sh -c "just check && just test"
Using the devshell this way keeps the environment consistent without requiring an interactive shell session.
When to use which command
just pre-commit- Runs clippy, format check, and tests. Use before committing to verify code is ready.just fix- Auto-fixes all issues (cargo fix, clippy fix, formatting). Use to resolve lint/format errors.just check- Runscargo check --all-features. Fast type checking without full compilation.just clippy- Runscargo clippy --all-featureswith warnings as errors. Use for lint checking.just test- Runscargo test --all-features. Use after making functional changes.just build- Runscargo build --all-features. Use when you need a full build.just fmt- Runscargo fmt --all. Use to format code.just ci- Runsnix flake check, which is comprehensive but slow. Agents should almost never need this. It's primarily for CI pipelines and final verification before merging.
Version Control
This project uses jj (Jujutsu) for version control.
Create new commits frequently as checkpoints during development. Small, incremental commits make it easier to track progress, revert problematic changes, and understand the evolution of the codebase. Don't wait until a feature is complete—commit working states along the way.
Commits
Follow the conventional commits format.
Conventions
- NEVER use anyhow errors. Always use well-typed errors with thiserror.
- If you need to convert errors to a fuzzy representation for user-facing messages, use eyre, NEVER anyhow.
- Prefer to preserve the original error rather than swallowing it and re-raising a new error type. Swallowing errors and raising new ones in their stead typically means that we'll lose information about the root cause of the error.
- Algebraic data types are useful. Use them where appropriate to write more ergonomic, well-typed programs.
- You generally should not implement the
Defaulttrait for structs unless explicitly instructed. - In production code, DO NOT unwrap errors. Use the try operator
?and propagate errors appropriately. In tests,.unwrap()andpanic!are allowed for brevity, but preferassert!orexpect()with descriptive messages where possible. - NEVER use panic! in production code. Handle errors properly instead of panicking.
- When adding new packages, prefer to use
cargo add, rather than editing Cargo.toml. - The workspace Cargo.toml uses glob pattern
crates/*to include all crates in the workspace.
Cancellation Propagation
- For user-scoped operations, thread an operation-scoped
CancellationTokenthrough the entire async call chain, including helper functions and spawned tasks. - Do not create fresh root tokens (
CancellationToken::new()) inside operation logic unless the work is intentionally detached from user cancellation. - If a callee needs local cancellation control, derive a child token from the operation token so parent cancellation still propagates.
- Any async API that can block or cause side effects should accept a cancellation token (or a context object carrying one) rather than implicitly creating its own.
- Check cancellation before committing side effects (for example session config writes, state mutations, event emission) and after long await boundaries.
- Add cancellation tests for new operation flows that assert canceling an in-flight operation prevents post-cancel side effects.
Type-Safe Identifiers
When using primitive types (integers, UUIDs, strings) as identifiers, always wrap them in dedicated newtype structs to ensure type safety.
// BAD: Raw primitives are easily confused
fn get_user(user_id: i64, org_id: i64) -> User { ... }
get_user(org_id, user_id); // Compiles but wrong!
// GOOD: Newtype wrappers prevent mistakes
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct UserId(pub i64);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct OrgId(pub i64);
fn get_user(user_id: UserId, org_id: OrgId) -> User { ... }
get_user(org_id, user_id); // Compile error!
When to use typed identifiers:
- Database primary/foreign keys
- External API identifiers (Stripe IDs, GitHub IDs, etc.)
- Session/request/correlation IDs
- Any identifier that could be confused with another of the same primitive type
Recommended derives:
Debug,Clone,Copy(for small types),PartialEq,Eq,Hashserde::{Serialize, Deserialize}with#[serde(transparent)]for seamless JSONsqlx::Typeor equivalent for database types
UI Theming
- NEVER hardcode colors in TUI code. Always use the theme system to fetch colors.
- Use
theme.component_style(Component::...)to get the appropriate style for UI components. - This ensures consistent theming and allows users to customize their color schemes.
Error Handling
IMPORTANT: Never use anyhow for error handling in this codebase. All errors must be well-typed using thiserror.
The steer-core crate uses typed errors with thiserror. The main error type is crate::error::Error which contains variants for all core error types:
Api(ApiError)- For API-related errorsSession(SessionError)- For session management errorsTool(ToolError)- For tool execution errorsValidation(ValidationError)- For input validation errorsIo(std::io::Error)- For I/O operations- And other domain-specific variants
Each module typically defines its own error type (e.g., ApiError, SessionError) that gets included in the main Error enum. This approach provides better error context and type safety compared to string-based errors.
Note: If you need to present errors to users in a more readable format, use eyre for error reporting at the UI boundary, but NEVER use anyhow. The core logic should always use typed errors.
Tool Contract (steer-tools ↔ steer-core)
steer-tools is the contract. It owns tool names, display names, param/result types, and tool-specific execution errors. steer-core implements behavior without redefining the contract.
Rules
- Tool names and schemas live in
crates/steer-tools/src/tools/*. steer-core must import constants/types fromsteer_tools::tools::*(no duplicate name definitions or param structs in core). - Each tool contract defines a
ToolSpecimplementation (name, display_name, params/result/error types). steer-core static tools must reference the contractToolSpecinstead of redefining metadata. ToolSchemaincludesdisplay_name; always populate it (fallback tonameonly for legacy data). Descriptions live in steer-core and may be implementation-specific.- Tool-specific execution errors live in
crates/steer-tools/src/error.rsasToolExecutionErrorvariants, with per-tool enums in each tool module. These are the only details allowed for execution failures. - steer-core static tools return
ToolError::Execution(ToolExecutionError::<Tool>(...))for execution failures, and must not invent new error kinds. ToolError::InvalidParamsalways includes{ tool_name, message }. There is noToolError::Io; map raw I/O failures toToolExecutionError::External(or a tool-specific...Error::Workspace(...)where applicable).- Validation/approval/cancellation/timeouts are separate top-level
ToolErrorvariants; execution details belong underToolExecutionErroronly. - Workspace failures should be mapped to
WorkspaceOpErrorand wrapped in the appropriate tool-specific error enum.
Adding a new tool
- Define the contract in
crates/steer-tools/src/tools/<tool>.rs:*_TOOL_NAME, aToolSpecimpl (name, display_name, params/result/error types), and a tool-specific error enum. - Export it from
crates/steer-tools/src/tools/mod.rs. - Implement the tool in
crates/steer-core/src/tools/static_tools/<tool>.rsusing the contract types. - Register the tool in
crates/steer-core/src/tools/factory.rs. - If it is read-only, include it in
crates/steer-core/src/tools/static_tools/mod.rsREAD_ONLY_TOOL_NAMES.
Crate Architecture (Polylith-style)
All crates are organized under the crates/ directory following a polylith-style architecture:
crates/steer-proto/- Protocol buffer definitions and generated codecrates/steer-core/- Core domain logic (LLM APIs, session management, tool execution backends)crates/steer-grpc/- gRPC server/client implementationcrates/steer/- Command-line interface and main binarycrates/steer-tui/- Terminal UI library (ratatui-based)crates/steer-tools/- Tool trait definitions and implementationscrates/steer-macros/- Procedural macros for tool definitionscrates/steer-workspace/- Workspace trait and local workspace implementation (provider)crates/steer-workspace-client/- Remote workspace client implementation (consumer)crates/steer-remote-workspace/- gRPC service for remote workspace operations
Nix Development Environment
The project includes a Nix flake for reproducible development environments. To preserve shell aliases and configurations:
-
Using nix-direnv (recommended): Install with
nix profile install nixpkgs#nix-direnv, thendirenv allow. This preserves your parent shell environment. -
Custom shell config: Copy
.steer-shell.nix.exampleto.steer-shell.nixand customize it with your preferred aliases and configurations. -
Direct sourcing: The flake's shellHook automatically sources your
.zshrc/.bashrcto preserve aliases.
Dependency Graph
The crates must maintain a strict acyclic dependency graph:
steer-tools → steer-workspace → steer-core → steer-grpc → clients (tui, cli, etc.)
↓ ↑
steer-macros steer-workspace-client
Key principles:
- Provider/consumer separation: workspace trait (provider) is separate from remote client (consumer)
- No circular dependencies: each crate has clear, one-way dependencies
- Tool execution (backends) is separate from workspace operations
Workspace Orchestration (Environments + Workspaces)
Steer separates execution environments (sandboxes) from VCS workspaces (jj workspaces or git worktrees) that live inside them. An environment can host multiple repos; each repo can have multiple jj workspaces or git worktrees, while no-VCS repos are treated as single-workspace only.
Key points:
- Identifiers are type-safe newtypes:
EnvironmentId,RepoId,WorkspaceId. RepoRefandWorkspaceRefscope repos/workspaces to an environment for session affinity and UI grouping.- Workspace orchestration supports jj workspaces and git worktrees; no-VCS repositories remain single-workspace only.
- Managers live in
steer-workspace(provider); client access goes throughsteer-grpcandsteer-remote-workspace(no in-process shortcuts).
Crate Responsibilities
steer-proto
- ONLY contains .proto files and tonic-generated code
- Defines the stable wire protocol
- No business logic whatsoever
steer-workspace
- Defines the Workspace trait for environment and filesystem operations
- Provides LocalWorkspace implementation
- NO tool execution logic - purely environment/filesystem focused
- Exports EnvironmentInfo, WorkspaceConfig types
steer-workspace-client
- RemoteWorkspace client implementation
- Depends on steer-workspace for trait definitions
- Uses gRPC to communicate with steer-remote-workspace service
steer-core
- Pure domain logic (LLM APIs, session management, validation)
- Tool execution backends (LocalBackend, McpBackend)
- MUST NOT:
- Import steer-grpc
- Return or accept proto types in public APIs
- Know about networking, UI, or transport concerns
- CAN import steer-proto for basic type definitions only
- Imports steer-workspace for workspace operations
- Exports domain types (Message, SessionConfig) and traits (AppCommandSink, AppEventSource)
steer-grpc
- Network transport layer only
- The ONLY crate that knows about both core types and proto types
- Contains ALL conversions between core domain types ↔ proto messages
- Conversion functions must be pub(crate), never public
- Implements gRPC server/client that wrap core functionality
steer-remote-workspace
- gRPC service implementing remote workspace operations
- Wraps a LocalWorkspace to expose it over the network
- Used by RemoteWorkspace clients
Client crates (steer-tui, etc.)
- MUST go through steer-grpc, never directly access steer-core
- Even "local" mode should use an in-memory gRPC channel
- No special treatment - all clients are equal
Important Rules
- No proto types in core: If you see
steer_proto::in steer-core outside of basic imports, it's a violation - All conversions in grpc: Any function converting between core and proto types belongs in steer-grpc
- No in-process shortcuts: Clients must always use the gRPC interface, even for local operations
- Clean boundaries: Each crate has a single, clear responsibility
- Path references: When referencing files outside crates (e.g.,
include_str!), remember to account for the extra directory level:../../becomes../../../or../../../../ - Workspace vs Tools: Workspace handles environment/filesystem, backends handle tool execution - never mix these concerns
- Provider/Consumer split: Workspace trait provider (steer-workspace) is separate from remote consumer (steer-workspace-client)