exoagentd is a runtime daemon that manages:
- Providers — capability classes (trusted, unsandboxed). Hold secrets, make API calls. Either builtin or human-reviewed via the review cap.
- Exos — sandboxed programs (untrusted). Run in exoeval. Can only call
@tool()methods on destructured caps.
The review cap is the escalation boundary. The agent proposes changes (branch + PR), a human reviews on GitHub, approves or rejects.
A provider is a class whose instantiation is a cap. Builtin providers:
- sandbox — bwrap-based execution. Provides
execinside an isolated namespace with filtered networking (internet yes, LAN/host no). - pi — coding agent via pi SDK. Replaces pi's builtin tools with sandbox-backed versions.
- review — GitHub PR-based code review. Pushes branches, opens PRs, fetches reviews.
- storage — persistent KV + directory management in
.exoagent/storage/. - secrets — daemon-only secret store in
.exoagent/secrets/. Never exposed to exos.
Sandboxed programs. Caps are declared by destructuring:
export default async ({ sandbox, review, storage, pi }) => {
await pi.interactive({ stdio: true })
}Only the destructured caps are available. exoeval enforces this at the interpreter level.
if, for, while statements must always use curly brackets, even for single-line bodies.
// Good
if (condition) {
doSomething()
}
// Bad
if (condition)
doSomething()This is enforced by ESLint (curly: ['error', 'all']).
Use import type { ... } from '...' at the top of the file. Inline import() types should only appear in .d.ts files.
// Good
import type { Secrets } from './secrets'
// Bad
secrets?: import('./secrets').SecretsThis is enforced by ESLint (no-restricted-syntax on TSImportType).
Do not prefix private instance variables with _. Use private keyword only.
// Good
private session: AgentSession | null = null
private config: ReviewCapConfig
// Bad
private _session: AgentSession | null = null
private _config: ReviewCapConfigDo not cache values that are already available from config. Avoid redundant fields.
// Good — read directly from config
get token(): string {
return this.config.secrets.get('review', 'GITHUB_TOKEN')
}
// Bad — redundant cache of a config value
private cachedRepo: string | null = null
getRepo() {
if (this.cachedRepo) return this.cachedRepo
if (this.config.repo) { this.cachedRepo = this.config.repo; return this.cachedRepo }
// ...auto-detect...
}Make required config explicit. Don't chain fallbacks (config → env var → CLI tool → error). If something is required, make it a required config field.
// Good — token comes from one place
interface ReviewCapConfig {
secrets: Secrets // required
repo: string // required
}
// Bad — fallback chain
interface ReviewCapConfig {
secrets?: Secrets // try this first
token?: string // then this
// then GITHUB_TOKEN env, then `gh auth token`...
}For classes that need initialization (DB setup, directory creation), use:
- A
privateconstructor that takes a fully-ready dependency (e.g. an open DB handle). - A
static create(...)method that runs initialization once.
class StorageCap {
private constructor(root: string, db: Database.Database, provider: string) { ... }
static create(root: string, provider = 'default'): StorageCap {
// Initialize directory, open DB, create tables
return new StorageCap(resolvedRoot, db, provider)
}
}Normalize internal data structures to be compatible with API results. Don't create intermediate types just for mapping.
// Good — push API-compatible objects directly
for (const c of apiComments) {
comments.push({ id: c.id, path: c.path ?? '', body: c.body ?? '', diffHunk: c.diff_hunk ?? '' })
}
// Bad — separate wrapper type that just renames fields
interface InternalComment { ... }
function toPublic(c: InternalComment): ReviewComment { ... }Put descriptions in JSDoc/typedoc comments (which end up in .d.ts files), not in .describe() calls on zod schemas.
// Good — description in JSDoc
/**
* Push a branch to GitHub and open/update a PR.
* Remote branch will be auto-prefixed with `exoagent-<agent>/`.
*/
@tool(z.object({ branch: z.string(), title: z.string() }))
async openPR(...) { ... }
// Bad — description in zod .describe()
@tool(z.object({
branch: z.string().describe('Branch name to push'),
title: z.string().describe('PR title'),
}))Use regular top-level imports. Only use inline await import(...) when there's a real reason (circular dependency, conditional loading for performance).
// Good
import { readFile, rm } from 'node:fs/promises'
// Bad (unless justified)
const { rm } = await import('node:fs/promises')Don't create a file for a class that just wraps a single function. Inline small cap classes in the file that uses them.
Never encode access tokens into git remote URLs. Git push should use the host's SSH key or credential helper. Only GitHub API operations (create PR, fetch reviews) use the token, attached directly to HTTP requests from the host.