One small CLI for AI agent harnesses: a graph memory your agents (and you) can read and write, and a web console to view, monitor and run the agents themselves. One Rust binary for Linux, macOS and Windows, with no runtime, database, server or account to set up.
It is named for Indra's net (Indrajala): an endless net with a jewel at every knot, each jewel reflecting all the others. That is the memory model - everything links to everything - and Indra, king of the devas, is a fitting name for the thing that commands the agents.
| Command | What it does |
|---|---|
indra mem ... |
Graph memory: Markdown notes with typed [[links]], ranked recall that follows links, a terminal tree view. Obsidian-compatible. |
indra serve |
Web console: agents, skills and prompts (view and edit), graph maps, memory, live run monitoring, and launching agents with a live view of their subagents and tool calls. |
indra ls, indra check |
The console's picture in the terminal, and a config check. |
indra nodes, indra run |
Agents on this machine and on remote ones over SSH: node health, and multi-step workflows run in dependency order across them. |
indra init |
A commented indra.toml for custom harnesses, runners and presets. |
indra review, indra publish, indra mem record-run |
The deterministic parts of a review: the PR review cache, local metrics, posting to GitHub, publish plans, recording a run into memory. See Review tooling. |
indra skills |
Sync the skills mirror, or install the skills for Claude Code, Codex or opencode. |
The security harness is the default profile, any Claude Code project is picked up automatically, and any other harness is a few lines of TOML.
Install
One line (picks the build for your machine, checks its SHA-256, puts it on your PATH):
| |
Then indra serve --open. The installers also set up Tab completion for your shell. They take
INDRA_VERSION=0.7.0 for a specific release, INDRA_INSTALL_DIR for another folder
(default ~/.local/bin, or %LOCALAPPDATA%\Programs\indra), INDRA_AUTO_UPDATE=1 to turn on
automatic updates, and INDRA_NO_COMPLETIONS=1 to skip completion.
Updates. indra update installs the newest release (--check only asks; --version picks
one), checking its SHA-256. Once a day, when you use indra in a terminal, it looks for a new
release in the background and the next command tells you, at most once a day. To have it install
new releases by itself:
The check runs at most once a day, in a detached indra update --background, never slows a
command down, and also runs every few hours inside a long-running indra serve (the console's
version shows when a new one is out; an automatic update takes effect when indra serve
restarts). INDRA_NO_UPDATE_CHECK=1 turns checks off entirely, and they are off under CI and for
the remote side of a node. What it knows is in <data dir>/update.json.
Tab completion. The installers set it up; by hand:
For bash, zsh and PowerShell, --install writes the script under indra's data directory and one
marked block (# >>> indra completions >>>) in ~/.bashrc, ~/.zshrc or $PROFILE that loads
it; fish gets ~/.config/fish/completions/indra.fish. indra update refreshes the script, so
new commands complete after an update.
Prebuilt, by hand: download indra-<target> from a
release (each file has a .sha256, and
SHA256SUMS lists them all), rename it to indra (indra.exe on Windows) and put it on your
PATH. Builds exist for Linux (x86_64 and arm64, static, any distro), macOS (Apple silicon and
Intel) and Windows (x86_64).
One binary for every role. The console (indra serve) and a machine that runs agents for it
(indra node connect, or plain SSH) are the same file, like gh or docker: one download, one
version to keep in step, no mismatched pieces.
From crates.io or source (any platform with Rust 1.89+):
# or the newest code:
# or, from a clone:
Memory: indra mem
Notes are plain Markdown files with [[wikilinks]] and typed relations. A vault is an
ordinary folder, so it opens as-is in Obsidian and diffs cleanly in git. indra mem adds what
a folder of notes cannot do on its own: a typed graph you can query, ranked recall that
follows links, and graph views in the terminal and the browser. (indra m is a short alias.)
$ indra mem recall "django orm raw sql" -n 3
# Memory recall: django orm raw sql
## Django extra() is a raw SQL sink
_id: django-extra-is-a-raw-sql-sink | type: learning | tags: sqli, django | score: 1.00 | via: search_
QuerySet.extra(where=[...]) takes raw SQL. Any f-string into it is a SQLi sink.
links: -> instance-of SQL injection; -> mitigated-by Parameterized queries
## Parameterized queries
_id: parameterized-queries | type: pattern | score: 0.39 | via: Django extra() is a raw SQL sink -[mitigated-by]->_
Bind input as parameters. Identifiers cannot be bound; allowlist them.
The second result never mentions Django. It was recalled because the first one links to it,
and the via: line says so.
Why not just Obsidian?
Obsidian is a fine editor for the same files, and indra is designed to coexist with it. What
indra mem adds:
| Obsidian | indra mem | |
|---|---|---|
| Storage | Markdown folder | the same Markdown folder |
| Links | untyped [[links]] |
untyped and typed: mitigated-by:: [[x]] (Dataview syntax) |
| Graph queries | visual only | tree, neighbors, path, --rel filters, JSON output |
| Retrieval | keyword search | BM25 search + spreading activation over the graph, with explanations |
| Use from scripts and AI agents | plugins, GUI | a CLI with --json on every command |
| Safe for parallel writers | no | cross-process lock, atomic writes |
| Footprint | Electron app | one binary (memory and console), Linux / macOS / Windows |
| Licence | proprietary | MIT |
Quick start
Note format
title: Django extra() is a raw SQL sink
type: learning
tags: [sqli, django]
aliases: [extra() sink]
source: run-20261113-101500
created: 2026-11-13T10:15:00Z
updated: 2026-11-13T10:15:00Z
QuerySet.extra(where=[...]) takes raw SQL. See also [[orm-escape-hatches]].
- -
Every field is optional; a bare Markdown file is a valid note. What indra reads:
| Syntax | Meaning |
|---|---|
[[target]], [[target|label]], [[target#heading]] |
edge of type related |
rel:: [[target]], - rel:: [[a]], [[b]] |
typed edges (Mitigated By normalises to mitigated-by) |
[label](other-note.md) |
edge of type related |
key: "[[target]]" in frontmatter |
edge of type key (e.g. parent: "[[x]]") |
tags: in frontmatter, inline #tags |
tags (nested #a/b works with --tag a) |
title:, else the first # heading, else the file name |
title |
type: (or kind:) |
note type, used for filters and colours |
Links inside code blocks and inline code are ignored. Hidden folders (.obsidian, .git,
.trash, .indra) and symlinks are skipped.
Name resolution. [[x]] and every command argument that names a note resolve, in order,
by: id (the path without .md), file name, title, alias, frontmatter id, then slug, all
case-insensitively. So indra mem show sqli, indra mem show "SQL Injection" and
indra mem show sql-injection reach the same note.
Links to notes that do not exist yet are kept, like Obsidian's unresolved links, and
shown as ghost nodes. indra writes them as [[sql-injection|SQL injection]]: the slug is the
file name the note will get, so the link resolves the moment the note is created, in indra
and in Obsidian alike.
Commands
Every command accepts --vault DIR and --json.
| Command | What it does |
|---|---|
init [DIR] |
create a vault (a folder with a .indra/ marker) |
where |
print the resolved vault path |
add TITLE [-b TEXT|-] [-t TYPE] [--tag T] [--alias A] [-l NOTE] [-r REL=NOTE] [--source S] [--folder F] [-u] |
create a note; -u/--upsert merges into an existing note with that name (idempotently) |
append NOTE TEXT|- |
add a paragraph above the Relations section |
link FROM TO [-r REL] |
add a typed relation; a no-op if it already exists |
unlink FROM TO [-r REL] |
remove relation lines |
tag NOTE TAG... [--remove] |
edit frontmatter tags |
show NOTE |
the note, its links, backlinks and unresolved links |
list [-t TYPE] [--tag T] |
list notes |
search QUERY [-n N] [-t TYPE] [--tag T] |
ranked full-text search with snippets |
recall QUERY [-n N] [-d DEPTH] [--max-chars N] |
graph-aware recall, formatted as context for an LLM |
neighbors NOTE [-d DEPTH] [-r REL] |
the neighbourhood, either direction |
path FROM TO |
the shortest chain of relations between two notes |
tree [NOTE] [-d DEPTH] [-r REL] |
draw the graph as a text tree in the terminal: around one note, or every connected group in the vault |
tags, stats |
tag counts; note, link, type, relation and hub counts |
check [--strict] |
lint: unresolved links, ambiguous names (exit 1), orphans |
export [-f html|json|dot|mermaid] [-o FILE] [--no-body] |
export the graph |
rm NOTE |
delete a note and report the links it leaves unresolved |
import DIR [--from obsidian|notion|github-wiki|markdown] [--folder F] [--tag T] [--overwrite] [--dry-run] |
copy notes from an Obsidian vault, a Notion export, a GitHub wiki or any Markdown folder, converting links to wikilinks (see the guide) |
Text can be read from stdin with -: git log -1 --format=%B | indra mem add "Release notes" -b -.
How recall works
- Search. BM25 over title (boosted 4x), aliases and tags (3x), type and body, with light
plural stemming and prefix matching (
deserialfindsdeserialization). - Spread. Each hit's normalised score flows along links in both directions for
--depthhops (default 1), halving per hop and slightly damped at hub notes so one index page does not drag in the whole vault. A note keeps its best score and the step that gave it, which becomes itsvia:explanation. - Budget. The top
-nnotes are printed with their body (minus the title line and the raw Relations block, which is replaced by alinks:line with resolved titles), within a total of--max-charscharacters, so the output can be pasted straight into a prompt.
Vault location
--vault DIR, else $INDRA_VAULT, else the nearest parent of the working directory that
contains a .indra/ folder (so a project can carry its own vault), else a per-user default:
| OS | Default vault |
|---|---|
| Linux, BSD | $XDG_DATA_HOME/indra/memory, or ~/.local/share/indra/memory |
| macOS | ~/Library/Application Support/indra/memory |
| Windows | %LOCALAPPDATA%\indra\memory |
Read commands on a vault that does not exist yet return empty results; the first write creates it.
Viewing the graph
Everything works from the terminal. indra mem tree draws the graph as text:
$ indra mem tree sqli
SQL injection (sql-injection) [vuln-class]
|-- <- defends-against Parameterized queries
| `-- <- mitigated-by Django extra() is a raw SQL sink (*)
|-- <- instance-of Django extra() is a raw SQL sink
| |-- -> mitigated-by Parameterized queries (*)
| `-- <- instance-of SH-SQLI-003 report export
`-- <- refines False positive: sanitize_sql helper
-> is a link out, <- a backlink. Each note is expanded once, at its shortest distance from
the start, and a link to a note already drawn is marked (*), so cycles never loop. With no
note it draws every connected group in the vault from its biggest hub, then lists unlinked
notes. Connectors are plain ASCII, so it renders the same in any console.
For a visual view, indra mem export -o graph.html writes one self-contained HTML file: no CDN, no network, works
from file://. Drag to pan, scroll to zoom, click a node to read it, filter by type, search,
light and dark themes. Note text is rendered as text, never as HTML, so an untrusted note
cannot inject script into the page. -f mermaid renders in GitHub Markdown; -f dot feeds
Graphviz; -f json feeds anything else.
Or open the vault folder in Obsidian. Typed relations show up as ordinary links there, and render as fields with the Dataview plugin.
Using it from AI agents
indra mem was built for agents that need memory across sessions:
- Recall before acting:
indra mem recall "<task keywords>" -n 8 --max-chars 4000. - Record after acting:
indra mem add "<one specific learning>" -t learning --rel ... --upsert.--upsertmakes retries and repeated learnings safe. - Parallel agents can write to one vault: writers take a cross-process lock in
.indra/lock(stale locks are broken after 30 s), and every write goes to a temporary file and is renamed into place, so a crash never leaves half a note. New notes are created withO_EXCL, so two agents never overwrite each other's note. - Treat recalled text as data. A note can contain anything someone wrote into it.
The indra plugin's sh-memory skill is a complete example: it recalls past
findings and known false positives before hunting, and records each run's results afterwards.
Design notes and limits
- The files are the database. Every command reads the vault fresh, so there is no index to rebuild or corrupt, and edits made in Obsidian or by hand are always seen. A 5,000-note, 20 MB vault answers any command in about 0.25 s on a laptop; past roughly 50,000 notes an index would be worth adding.
- Edits are surgical. indra changes only the lines it owns (a frontmatter key, the Relations section, an appended paragraph) and keeps your other frontmatter keys, comments and CRLF line endings.
- Frontmatter is a YAML subset: scalars,
[a, b]lists and- itemlists. Nested maps and multi-line strings are skipped rather than rejected. - Dependencies:
clap,serde,serde_json. Nothing else.
Console: indra serve
indra 0.1.0
harness indra (~/code/indra/plugins/indra)
harness claude-code (~/code/indra)
runners claude
Open: http://127.0.0.1:7878/?token=3f9c...
(the token in this URL is the password; keep it private)
Open the printed URL once. It exchanges the token for a cookie and you are in. Add --open to
open the browser for you, --read-only to turn off launching and editing.
| Page | What it shows |
|---|---|
| Agents, Skills, Prompts | Everything a harness is made of, with model, tools and description. Open one to read, edit, download or delete it, and see what it uses and is used by; create a new one from a template or import .md files (they go where the harness's first simple pattern points, like agents/*.md). A save is refused if the file changed since you loaded it. |
| Graph map | Pipeline stages run agents, agents spawn agents and invoke skills (a family reference such as sh-kb-* loads every member), and everything reads its prompts. |
| Knowledge | The indra mem vaults the harness reads and writes: search, read, edit, link and delete notes, the graph, and its health (dangling links, orphans). Also the learnings inbox: what agents proposed they learned, to accept into memory or discard. See Context, memory and learnings. |
| Runs | Each run directory, live: stage progress, results per agent or class, status and severity counts, and every file the run wrote. Refreshes every 3 s. Start a pull request review from here; on a run, triage its findings, compare it with an earlier run, record it into memory, publish it or post it. See Reviewing pull requests from the console. |
| Marketplace | Plugins, skills and agents from Claude Code plugin marketplaces (Anthropic's built in, add your own) and skill repositories: look inside, install for Claude Code, Codex or a harness, or as a Claude Code plugin; update and remove. See Marketplace. |
| Plan and delegate | Under Launch: a planner profile writes a plan, you review and edit it, worker profiles (cheaper or local models) carry out each step, a reviewer checks the result; cost per role. See Plan and delegate. |
| Launch, Processes | Start an agent through a runner and follow it. With Claude Code you see every subagent it spawns, each subagent's own tool calls, refused permissions, the final cost, how full its context window is, and the memory it started with. Stop ends the whole process tree. |
| Board | Work items in columns, run by agents with a profile, imported from and reported back to Jira, GitHub Issues or ClickUp. Items can be edited and removed, and the board exported to or imported from JSON. See the board guide. |
| Terminal | A real terminal in the browser (with indra serve --shell): your shell or Claude Code in a target, or an agent's own session. See Terminals. |
| Settings | Everything in indra.toml, edited in the browser: runners, nodes, workflows, presets, harnesses, the agents-at-once limit, and the file itself as text. Each section exports to and imports from JSON (same names are replaced after you confirm); the File tab downloads indra.toml or loads one to check and save. Secret values never leave. See Settings. |
The same picture without a browser:
Profiles
A profile tells indra where a harness keeps its parts. Two are built in and detected automatically:
| Profile | Found when | Agents | Skills | Prompts | Runs |
|---|---|---|---|---|---|
indra |
you run inside this repository, or the indra plugin is installed under ~/.claude/plugins |
agents/*.md |
skills/*/SKILL.md |
references/* |
<target>/.security-harness/{run,pr}-*, stages recon -> hunt -> chain -> verify -> report |
claude-code |
the project or ~/.claude has agents, skills or commands |
.claude/agents/*.md |
.claude/skills/*/SKILL.md |
CLAUDE.md, AGENTS.md, commands |
- |
A custom harness is a few lines of TOML, in indra.toml (current directory), then
~/.config/indra/indra.toml, or --config FILE. indra init writes a commented starter, and
the console's Settings page edits all of it.
[[]]
= "research-agents"
= "."
= ["agents/*.md"]
= ["skills/*/SKILL.md"]
= ["prompts/**/*.md"]
= ["{target}/.memory"] # memory vaults; {target} expands per target
= ["../project-a"]
= ".runs/*" # one directory per run, under each target ({a,b} for alternatives)
= ["results.jsonl"] # JSONL results inside a run
= "agent" # which field names the agent that produced a result
= [
{ = "plan", = "planner", = "plan.md" },
{ = "research", = "researcher", = "results.jsonl" },
{ = "write", = "writer", = "report.md" },
]
Globs support *, ?, **, ~/ and absolute paths. Agent and skill files are Markdown with
optional frontmatter (name, description, model, tools or allowed-tools), the format
Claude Code uses. A stage is done once its done_when file exists; while a run is live, the
stage whose file changed last is shown as running. Graph edges come from the files
themselves: if planner.md mentions researcher, the planner spawns the researcher.
Runners
A runner is how indra starts an agent. Built-in runners are shown when installed:
| Runner | Command | Live view |
|---|---|---|
claude |
claude -p {prompt} --output-format stream-json --verbose |
text, every tool call, subagent spawns and results, permission refusals, cost |
codex |
codex exec {prompt} |
output lines |
gemini |
gemini -p {prompt} |
output lines |
[[]]
= "claude-edits"
= "claude"
= ["-p", "{prompt}", "--output-format", "stream-json", "--verbose", "--permission-mode", "acceptEdits"]
= "claude-stream-json" # or "text"
[[]] # saved prompts on the Launch page
= "Nightly quick review"
= "indra"
= "claude"
= "/sh-security-review {target} depth:quick"
The agent runs in the chosen target with your user's permissions and your agent CLI's own
permission settings (for Claude Code, .claude/settings.json); indra does not loosen them.
Stop sends SIGTERM to the runner's process group (Windows: ends the process tree) and kills it
after 5 s. Output is kept in memory for the live view and written to logs/ in the indra data
directory.
Settings
Everything the config file holds can be changed from the console, without a restart:

| Tab | What you set |
|---|---|
| General | whether the built-in profiles load, how many agents run at once on this machine, WebRTC for nodes that dial in and its STUN servers, and (for the next start) the listen address and read-only mode |
| Profiles | runner, model, where it runs (Anthropic or Ollama, or its subagents on Ollama), permission mode, tool rules, environment and secrets, memory and reflection; templates included |
| Runners | add, edit, duplicate or delete runners; Customize a built-in one (claude, codex, gemini) to change its arguments, including how it picks a model (model_args) |
| Nodes | remote machines: SSH destination (or none, for a node that dials in), port, key, capacity, targets |
| Workflows | workflows and their tasks: node, runner, profile, model and provider, component or sub-workflow, target, prompt, what each runs after, retries (the graph editor draws the same) |
| Components | reusable tasks a workflow task can start from |
| Presets | saved prompts for Launch (Launch also has Save as preset) |
| Teams | plan and delegate: the planner, worker and reviewer profiles |
| Marketplaces | Claude Code plugin marketplaces and skill repositories to install from |
| Harnesses | custom harnesses, or Customize a built-in profile |
| Connectors | Jira, GitHub Issues, ClickUp and Linear boards to import work items from and report back to (imports follow pages, up to 500 items) |
| Secrets | tokens agents and connectors use, encrypted, scoped to profiles and machines |
| File | the whole indra.toml as text, with Check before Save |
How a save works:
- Checked first. The new file is loaded exactly as
indra servewould load it (names unique, runners contain{prompt}, workflows have no cycles, SSH destinations are valid, ...). If it would not load, nothing is written and the page says why. - Applied at once. Launch, Nodes and Workflows use the new settings straight away. Agents that are running keep going as they were.
- Your file stays yours. A form rewrites only its own section: comments, order and formatting elsewhere in the file are kept, as is the comment above the section. Writes are atomic (a temporary file, then a rename).
- No lost edits. If the file changed on disk after the page loaded it, the save is refused and the page reloads the file.
- Where it is saved: the config file that was loaded, or
--config, or, if there was none, a new~/.config/indra/indra.toml(%APPDATA%\indra\indra.tomlon Windows). Relative paths in it are relative to that file's folder.
The listen address, read-only mode and --target are fixed while the console runs; they are
shown, and General saves the first two for the next start.
Security model
The console can start programs and rewrite prompts, so it is built like a remote shell:
- Loopback only by default. Binding elsewhere prints a warning.
- A token on every API call, random per start (or
--token/INDRA_TOKEN, 16+ characters), sent as anHttpOnly,SameSite=Strictcookie orAuthorization: Bearer, and compared in constant time. - Users and roles, for teams.
indra user add NAME(or Settings > Users) switches the console to sign-in with a name and password and three roles (viewer, operator, admin), checked by the server on every call, with an audit log of every change. The token then stays a key for tools only. See Users and roles. - No DNS rebinding. On loopback, a request whose
Hostis notlocalhost,127.0.0.1or[::1]is refused, token or not. - No CSRF. State-changing requests must be same-origin (
Origin,Sec-Fetch-Site) and JSON. - No shell. A runner's program runs directly with an argument vector and the prompt is a
single argument, so
; rm -rf ~in a prompt is just text. Runners come only from the config. - Settings are as powerful as a shell, and guarded like one. Defining a runner means choosing
a program to run, so editing settings needs everything above (token, same origin, loopback
Host) and is off when indra is
--read-only, when started with--lock-config, and when the console listens beyond this machine unless you also pass--allow-remote-config. - The browser terminal is opt-in. It is a shell on the machine, so it exists only when
indra serveis started with--shell, only on a loopback address and never read-only. On top of the token and Host checks, its WebSocket must come from the console's own origin (browsers apply no CORS to WebSockets), at most 8 are open at once, and each one is logged tologs/terminal.login the data directory. - Confined files. Prompts are read and written only if discovery found them; run files
resolve only inside their run directory (
.., absolute paths and escaping symlinks refused). - No injected markup. The UI renders every string with
textContent, under a strictContent-Security-Policywith nothing third-party. The terminal page alone allows inline styles, which xterm.js (vendored, MIT, served by indra) needs; scripts stay'self'.
Processes are tracked while indra serve runs. When it exits (Ctrl-C, SIGTERM, SIGHUP) it
stops every agent it started first; their logs stay on disk. See
Handling agent processes.
Agent control
An agent started through a Claude Code runner (format = "claude-stream-json") is driven over
Claude Code's control channel, the one the Agent SDK uses, so from its process page you can:
- Approve or deny tool requests. When the agent wants to run a tool its permission mode does not allow, the request appears under Waiting for you (and as a badge on Processes) with Allow, Allow always (adds Claude Code's suggested rule for the rest of the session) and Deny (with a reason the agent sees).
- Change its permission mode:
manual(ask for everything),acceptEdits,auto,plan(read and plan only),dontAsk(deny whatever would ask) orbypassPermissions. - Change its model for the turns that follow.
- Interrupt the current turn without ending the session.
- Send a follow-up message, answered after the current turn.
The prompt goes to the agent over stdin, and indra closes stdin after the last turn's result, so
the agent still exits when its work is done. Remote agents get the same controls: the hub forwards
them to the node. API: POST /api/procs/{id}/permission, /control and /message.
Profiles
A profile is a named way to run an agent: a runner with a model, permission mode, effort, tool rules and environment. Launch, presets, workflow tasks and fan-outs pick one, and model and mode can still be changed per launch or while the agent runs. Settings > Profiles has templates.
Built in, so nothing needs configuring: four Claude Code profiles and a team that uses them.
A [[profile]] or [[team]] with the same name replaces one (Customize in Settings), and
builtins = false turns them off.
| Name | What it is |
|---|---|
planner |
Opus, high effort, read-only tools: reads and plans, changes nothing |
worker |
Sonnet, accepts its own edits: does the work |
builder |
Haiku, accepts its own edits: small, well-specified steps, cheaply |
reviewer |
Opus, accepts its own edits: reviews and fixes small mistakes |
team plan-and-build |
planner plans, worker builds, builder takes the light steps, reviewer checks |
[[]]
= "planner"
= "claude"
= "opus"
= "plan"
[[]]
= "worker"
= "claude"
= "sonnet"
= "acceptEdits"
= ["Read", "Grep", "Glob", "Edit", "Bash(git diff:*)"]
[[]]
= "local" # Claude Code on a local model, through Ollama (0.14+)
= "claude"
= "qwen3-coder"
= "ollama" # ollama_url = "http://gpu-box:11434" for another machine
[[]]
= "lead-local" # Opus on Anthropic, its subagents on the local model
= "claude"
= "opus"
= "qwen3-coder"
= "ollama" # through indra's model router; no credential reaches Ollama
[[]]
= "lead"
= "claude"
= "opus"
= "haiku" # Claude Code's own subagents run on Haiku (CLAUDE_CODE_SUBAGENT_MODEL)
[[]]
= "learner"
= "claude"
= true # start with the memory notes relevant to the prompt
= true # propose learnings for review before finishing
For runners other than Claude Code, set model_args (e.g. ["--model", "{model}"]) to let a
profile choose the model (the built-in Codex and Gemini runners have it); permission modes and
tool rules are Claude Code's. Ollama works with Claude Code and Codex: see
Local models with Ollama. A workflow task can override its
profile's model and provider.
Secrets
Agents need tokens (a tracker's API, a cloud account, a model key). They never go in
indra.toml; a profile refers to one by name:
[[]]
= "board-worker"
= "claude"
= { = "secret:jira" }
Set the value in Settings > Secrets, or on a machine without a browser:
|
- Encrypted at rest in the indra data directory (
secrets/vault.json, XChaCha20-Poly1305). The key isINDRA_SECRETS_KEY(64 hex characters) when set, so it can live off the disk; otherwise a random key insecrets/vault.key, readable only by you. Anyone who can read your files as you can read both; the encryption protects copies of the vault file (backups, synced folders), not your account. - Write-only. The console, the API and the CLI show names, descriptions, scopes and use counts, never a value.
- Scoped. A secret can be limited to some profiles and some nodes. A launch outside its scope, or one that needs a secret that is not set, is refused before anything starts.
- Delivered at launch, in memory. The value goes into the agent's environment; to a remote node it travels inside the job over SSH and is never written to disk there.
- Redacted. The agent's live feed and log show
[secret:jira]instead of the value. - Audited. Each delivery is appended to
secrets/audit.log(secret, profile, node, process).
Pivot
On a process page, Pivot this agent gives it a new instruction without losing its work:
- Continue resumes the agent's own session (Claude Code
--resume), so the new instruction lands in the same conversation, with everything it has read and found. - Fresh starts a new session whose prompt carries a brief of the first agent: what it was asked, how it ended, and what it reported.
The original is stopped first unless you tick Keep this agent running, in which case a
Continue forks the session (--fork-session) and both go on. The pivot runs on the same node,
target and runner, and the two processes link to each other. API:
POST /api/procs/{id}/pivot with {"prompt": "...", "mode": "continue"|"fresh", "keep_running": false}.
A custom runner can continue sessions with resume_args = ["--resume", "{session}"] (and
fork_args). See Pivoting agents.
Plan and delegate
A strong model plans, cheaper ones build. Pick a team ([[team]]: planner, worker,
optional light worker and reviewer, each a profile) under Launch > Plan and delegate. The
planner reads the code and answers with a plan of small, self-contained steps; you can edit,
reorder, drop or add steps, then each runs as its own agent on the worker's model (the light
worker's for mechanical steps), in order, and the reviewer checks the result. Any runner can
fill any role, so Claude can plan while a local Ollama model builds. Settings has templates for
the profiles and teams. See Plan and delegate. For local models in any role, see
Local models with Ollama.
Knowledge: context, memory and learnings
Agents can start with what earlier agents learned, and finish by proposing what they learned:
- Memory at launch. Tick Give it relevant memory on Launch (or set
memory = trueon a profile): the notes from the harness's vaults that best match the prompt (graph-awareindra mem recall, six notes, about 4,000 characters) are appended after the prompt. The process page lists them. - Learnings. Tick Ask for learnings at the end (or
reflect = true): before a Claude Code agent's input closes, it is asked what a future agent should know. Its answer, and learnings people add from a process page orindra attach, wait in Knowledge > Learnings with their source (process, session, profile, directory) until you accept one into a vault, as alearningnote, or discard it. - Context meter. Each process page shows the tokens in the agent's context against its model's window, so you can pivot before it fills up.
- Manage memory on Knowledge: search, read, edit, link, create, download and delete notes, see the graph, and fix dangling links and orphans. Import Markdown files or a whole folder (an Obsidian vault, an unzipped Notion export, a cloned GitHub wiki, or an indra bundle), previewing first; export a vault as a re-importable bundle, an offline HTML graph, JSON, Mermaid or DOT. Learnings export and import as JSON.
See Context, memory and learnings.
Terminals
Two ways to work with agents from a terminal instead of the console's pages.
indra claude, Claude Code on a local model. indra claude -m qwen3-coder checks your
Ollama server and the model, then starts Claude Code in your terminal with every model call
going to Ollama (through indra's router, so no credential reaches it); --subagents MODEL keeps
Claude on Anthropic and runs only its subagents locally. See
Local models with Ollama.
indra attach, in your own terminal. It talks to a running console with its token: on
the same machine it finds the console by itself (indra serve writes console.json, owner-only,
to the data directory and removes it on exit); elsewhere pass --url and --token (or
INDRA_URL and INDRA_TOKEN).
$ indra agents
ID STATUS NODE RUNNER MODE / MODEL WAIT LAST
7 running local claude manual / sonnet 1 asks to use Bash: npm test
6 running build-box claude acceptEdits / o... reading src/auth/session.ts
$ indra attach 7
process 7 running claude on local in ~/code/my-api session 5f0c...
? asks to use Bash: npm test
[1] Bash: {"command":"npm test"}
/allow [n], /always [n] or /deny [n] [reason]
/always
? allowed Bash (always, for this session)
indra attach with no id picks an agent that waits for you, else the newest running one. It
streams the agent's events and takes commands: plain text is a message to the agent; /allow,
/always and /deny [reason] answer tool requests; /mode, /model and /interrupt steer it;
/pivot [--fresh] [--keep] <text> redirects it and follows the new process; /switch <id>,
/agents, /status, /stop, /learn <text> (a learning for the inbox), /quit. Its header
shows the agent's context size and the memory it was given. It works for agents on every node,
like the console.
A terminal in the browser. Start the console with indra serve --shell and the
Terminal page opens a real PTY in a new tab: your shell ($SHELL) or Claude Code's
interactive session in one of the harness's targets, or an agent's own session. The last one
runs the agent's runner with its resume_args (claude --resume <session>) in the agent's
directory; while the agent still runs it adds fork_args (--fork-session), so you explore a
branch and the agent carries on undisturbed. Process pages get an Open in terminal (or
Fork in terminal) button. Agent sessions open for agents on this machine only.
Terminals open as tabs in one terminals browser tab: each has its own tab with a status dot,
its number and a × to close it, and + New opens a shell or Claude Code (with a profile)
in any target. While that page is open, the console's buttons add a tab to it instead of opening
another window. Keys: Ctrl+Shift+T new, Ctrl+Shift+W close, Ctrl+Shift+PageUp/PageDown switch.
A terminal whose program exits keeps its tab (and its output) with Start again.
Which shell: $SHELL unless [terminal] says otherwise (also in Settings > General);
the Terminal page and the New form offer the configured shell, shells, and those installed from
/etc/shells (PowerShell and cmd on Windows), and nothing else can be started:
[]
= "/bin/zsh" # the default for new terminals
= ["-l"] # a login shell
= ["/opt/homebrew/bin/fish"]
The
Terminal page lists Running terminals on this machine, from any browser, with who opened
each and a Close button. Closing a terminal, by its ×, from the list, or by closing
the page, hangs it up and ends what it started, background jobs included
(GET /api/term/sessions, POST /api/term/sessions/{id}/close, admin only).
Nodes and orchestration
Agents can run on this machine and on any machine you can SSH into. A node is one such
machine; local is always there.
[]
= 4 # agents at once on this machine
[[]]
= "build-box"
= "ci@build-box.internal" # host, user@host, or a Host alias from ~/.ssh/config
= 2
= ["/srv/api", "/srv/web"] # codebases on that machine
Each remote node needs indra on its PATH (or set indra = "/path/to/indra"), and
ssh <destination> must work without a password prompt (keys or an agent). Then:
$ indra nodes
local ok laptop (macos aarch64, 10 cpus, indra 0.1.0, max 4 agents) runners: claude
build-box ok ci-7 (linux x86_64, 16 cpus, indra 0.1.0, max 2 agents) runners: claude, codex
gpu-box DOWN me@gpu-box ssh: Could not resolve hostname gpu-box: Name or service not known
In the console, Nodes shows each machine, its runners, its capacity and the review runs on
its disk (over SSH, or over the link for a machine that dials in). Click a remote run to copy its
text files to the hub (<data dir>/fetched/<node>/<run>) and open it like a local one: findings,
triage, comparison, memory, publish and post work on the copy, and changes stay in the copy.
Launch can start an agent on any node. A remote agent streams into the same
live view as a local one, with its subagents and tool calls, and Stop stops it on the remote.
Machines that dial in (no SSH)
A node can connect to the hub instead of being reached over SSH. On the Nodes page, Add a machine gives a one-time command; run it on that machine (with indra installed):
# later starts: indra node connect https://hub.example:7878 --name build-box --target /srv/api
-
Outbound only. The node opens a WebSocket to the hub (
/agent/connect); it needs no open port and no SSH account. The hub needs to be reachable by the node: bind it with--bind(and--allow-remote-configif you also want to edit settings remotely), ideally behind a TLS reverse proxy so the node useshttps:///wss://. -
Enrolment. The code works once, for 15 minutes. The node then gets its own random key, stored in the hub's encrypted secret store (never given to agents) and in an owner-only
agent.jsonin the node's config folder. -
Direct when possible. The WebSocket carries signaling; hub and node then open a WebRTC data channel (DTLS-encrypted) and job traffic moves onto it. On the same network that needs nothing; across NATs add STUN servers (
[server] ice_servers = ["stun:stun.example.org:3478"], or Settings > General) and TURN servers with their credentials:[[]] = ["turn:turn.example.org:3478", "turns:turn.example.org:5349"] = "indra" = "secret:turn" # resolved on the hub, sent to nodes over their linkWhen there is no direct path, traffic is relayed over the WebSocket.
[server] webrtc = false, orindra node connect --no-webrtc, keeps everything on the relay. The Nodes page shows which path each node uses. -
Reconnects on its own. Heartbeats every 5 s; a link silent for 15 s is dead. The node retries with exponential backoff and jitter (1 s up to 60 s). Jobs keep running through a drop: their output is numbered, the node keeps what the hub has not acknowledged and sends it again, and the hub drops duplicates, so each line arrives once, in order.
-
No orphans. The hub fails a node's jobs if it stays away for 2 minutes; the node stops its jobs if the hub is gone for 10 minutes, or comes back as a different hub process.
-
SSH stays as the fallback. A
[[node]]with ansshdestination is used over SSH whenever it has no live link.
The node runs only runners from its own configuration, in directories that exist on it, exactly
as with SSH. Builds without the webrtc feature (cargo install --no-default-features) relay
everything over the WebSocket.
Workflows
A workflow is a graph of agent tasks, each on a node, run in dependency order:
[[]]
= "nightly"
= "Review the services on the build box and the web app here, then summarise."
[[]]
= "api"
= "build-box"
= "/srv/api"
= "/indra full security review of {target}"
= 1 # one more attempt if it fails
[[]]
= "web"
= "local" # the default
= "../web" # local targets are relative to indra.toml
= "/indra full security review of {target}"
[[]]
= "summary"
= "."
= "Summarise tonight's reviews and list new critical findings."
= ["api", "web"] # runs only if both succeed
- A task starts when everything it
needshas succeeded; if one of them failed, it is skipped. - Tasks start only while their node has room (
max_parallel, counting every agent there). - A failed task is retried up to
retriestimes. - Cancelling stops the running agents and cancels what has not started.
{target}and{node}are filled into the prompt;runnerdefaults toclaude.- Workflows are checked when the config loads: unique task ids, known nodes,
needsthat exist, and no cycles.
Run one from the terminal (cron, CI) or from the console's Workflows page:
$ indra run nightly
17:01:29 workflow 'nightly' started
17:01:29 api build-box running (process #1, attempt 1)
17:01:29 web local running (process #2, attempt 1)
17:14:52 web local succeeded
17:21:07 api build-box succeeded
17:21:07 summary local running (process #3, attempt 1)
17:23:40 summary local succeeded
17:23:40 workflow 'nightly' succeeded
Inputs. A workflow can ask for values when it runs, and its tasks use them as
{input.NAME} in their prompt, target, profile or model:
[[]]
= "triage-pr"
[[]]
= "pr"
= "Pull request"
= true
= '[0-9]{1,9}' # text inputs may require a form
[[]]
= "target"
= "target" # one of the harness's targets
[[]]
= "depth"
= "choice"
= [{ = "", = "auto" }, { = "--tier=2", = "standard" }]
[[]]
= "dry_run"
= "bool"
= "true"
= "--dry-run" # what {input.dry_run} becomes when ticked
[[]]
= "profile"
= "profile" # a configured profile, or none
[[]]
= "review"
= "{input.target}"
= "{input.profile}"
= "/sh-pr-review {input.pr} {input.depth} {input.dry_run}"
Kinds are text (the default), choice, bool, target and profile. Each value is checked
when the workflow starts: required ones present, text matching its pattern and on one line,
choices and targets among their options, profiles configured. An empty input leaves no gap in
the prompt.
Built in: with the indra harness, two workflows are always there (a configured workflow of the same name replaces one): pr-review (pull request, checkout, profile, depth, fail-on, deep, dry run; dry run is on by default) and security-review (codebase, optional focus, profile). Run any workflow from Runs > Run a workflow or the Workflows page (both ask for its inputs), or from the terminal:
The graph editor. Workflows > New workflow (or Edit graph on a workflow) opens a
canvas: drag tasks, skills, saved tasks and other workflows in from the palette (or select one
and press Enter), drag from a step's right edge to another step to make it run after the first,
and edit the selected step on the right. Remove a step with its Delete step button, the x on
the selected step, or the Delete key (Undo brings it back); remove an arrow by selecting it
and pressing Delete. Saving writes the same [[workflow]] TOML, with each step's position, so
indra run and the text editor keep working.
Skills as steps. A step can run one of the harness's skills instead of a free prompt: drag it
from Skills in the palette, or write skill = "<name>". For Claude Code it runs as the
skill's slash command, /<skill> <prompt>, so prompt holds the skill's arguments (default
{target}); other runners are told to use the skill.
[[]]
= "recon"
= "sh-security-review"
= "{target} stage:recon" # becomes: /sh-security-review {target} stage:recon
Saved tasks ([[component]] in the file) are tasks kept for reuse: a task, a skill step or a
prompt with its profile, runner, node and target, that any workflow can drag in. A step that
starts from one overrides any field, and changing the saved task changes every workflow that
uses it. The editor's Save for reuse turns a step into one. (A task is one step of one
workflow; a saved task is a template for steps. Where a step runs is its node and
target, the repository or folder.)
[[]]
= "review"
= "/indra full security review of {target}"
= "worker"
= 1
[[]]
= "api"
= "review"
= "build-box"
= "/srv/api"
Shell steps. A step with run runs a command instead of an agent: bash (else sh;
PowerShell on Windows), in its target, on any node. It succeeds with exit code 0, and its output
is what later steps and conditions see. No value is ever pasted into a command: inputs, the loop
item and earlier results arrive as environment variables, so an input cannot change what runs.
[[]]
= "test"
= "cargo test 2>&1"
| Variable | What it holds |
|---|---|
INDRA_INPUT_<NAME> |
an input's value (name in upper case) |
INDRA_ITEM |
the item of a foreach copy |
INDRA_OUTPUT_<STEP>, INDRA_EXIT_<STEP>, INDRA_STATUS_<STEP> |
a finished step's output (last 8000 characters), exit code and state (- becomes _) |
INDRA_TARGET, INDRA_NODE, INDRA_WORKFLOW, INDRA_STEP |
where and what is running |
A command cannot contain {input.}, {item} or {steps.}; agent prompts can ({steps.test.output}
writes an earlier step's result into the prompt). A node refuses shell steps with
[local] shell_steps = false in its own indra.toml.
Conditions and failure handling.
[[]]
= "test"
= "cargo test 2>&1"
= true # a failure here does not fail the workflow
[[]]
= "fix"
= ["test"]
= "test.failed and input.autofix == 'true'"
= "worker"
= "The tests fail:\n{steps.test.output}\nFix the code."
[[]]
= "tell"
= ["fix"]
= "failure" # only if something it needs failed
= "echo \"fix failed: $INDRA_OUTPUT_FIX\" | mail -s ci team@example.com"
[[]]
= "clean"
= ["test", "fix"]
= "always" # once they finish, however they went
= "rm -rf target/tmp"
whenis a condition on earlier steps and inputs:<step>.succeeded,.failed,.skipped,.status,.output,.exit_code, andinput.<name>, with==,!=,contains,<,<=,>,>=,and,or,notand parentheses. False: the step is skipped. A step it reads must run before it (in itsneeds, directly or through others), so its result is known.on:success(the default: every step it needs succeeded, or failed withallow_failure),failure(one of them failed) oralways.- Skipped steps, a branch not taken, do not fail the workflow; a failure fails it unless that
step has
allow_failure.
Stop steps end the workflow early, as a success or a failure. One is usually guarded by a condition; when it is reached, steps that have not started are skipped and running ones are stopped. In the editor it is the Stop block, and the graph is drawn between a Start and an End block (Start leads to the steps that need nothing, the steps nothing needs lead to End).
[[]]
= "nothing-to-fix"
= ["test"]
= "test.succeeded"
= "success" # or "failure"
= "Tests pass; nothing to fix." # shown on the run page
Targets. In indra.toml, a step on this machine without a target runs in the harness's first
target (the folder the Terminal page shows); in a repository's workflow file, in that repository.
A step on another node needs its target.
Loops.
[[]]
= "lint"
= ["api", "web", "worker"] # or "{input.services}": commas or new lines
= "make -C services/$INDRA_ITEM lint"
[[]]
= "make-green"
= "Run the tests and fix what fails."
= "self.output contains 'all tests pass'"
= 3 # default 3, at most 20
foreachmakes one copy of the step per item (lint-1,lint-2, ...), run in parallel within the node's capacity;{item}in a prompt or target,$INDRA_ITEMin a command. Whatever needs the step waits for every copy. At most 50 items; none, and the step drops out.untilruns the step again after each success until the condition holds (self.output,self.exit_code, or other steps), at mostmax_runstimes, then fails. A failed run usesretriesas before.- The run page shows each step's condition, loop item, run count, exit code and output.
Create a workflow from a description. Workflows > Create with Claude: describe what it should do and where to save it, and Claude Code builds it with indra's MCP server. It reads the format and this machine's profiles, nodes, repositories and skills, looks at the repository if it needs to (build and test commands), validates the workflow until it is right, and saves it; the page then offers it in the editor. Claude Code runs with only the indra tools and read-only file tools allowed; anything else it asks for waits for your answer on its process page.
indra mcp is that MCP server, for any MCP client. In your own Claude Code:
Its tools: workflow_guide (the format and what this machine has), list_workflows,
get_workflow, validate_workflow (checked exactly as indra serve loads it) and
save_workflow (to indra.toml, keeping its comments, or to a repository's
.indra/workflows/). It never runs a workflow or a command.
Workflows as code. A workflow can live in the repository it works on, as
.indra/workflows/<name>.toml, to be reviewed, committed and shared like any other code. Every
console and indra run with that repository as a harness root or target picks it up, and a
git pull that changes the files is seen without a restart.
# api/.indra/workflows/triage.toml
[[]]
= "triage"
[[]]
= "recon"
= "sh-security-review"
= "{target} stage:recon"
= "planner" # a built-in profile, so it works for everyone
[[]]
= "lint"
= "lint" # a saved task kept in the same file
= ["recon"]
[[]]
= "lint"
= "Run the linters on {target} and fix what they report."
- A step without a target runs in that repository; a relative target is inside it.
- The file holds
[[workflow]]and[[component]]tables, in the same format asindra.toml. - In the editor, Saved in chooses between this machine's
indra.tomland a repository (the file's path is shown); saving to a repository also copies in the saved tasks the workflow uses, so the file stands on its own. Moving a workflow between the two moves it. - A workflow in your
indra.tomlwith the same name wins. A file that cannot be used (bad TOML, or a profile or node only its author has) is listed on the Workflows page and never stops the console; the built-in profiles exist everywhere, so shared workflows that use them just work. - Saving to a repository needs the admin role, like other settings.
Workflows compose. A task with workflow = "<name>" runs that workflow as one step: its
first tasks start after the step's needs, and anything that needs the step waits for its last
tasks. Its tasks show up as <step>__<task> in the run. A workflow cannot include itself.
indra run exits 0 only if every task succeeded; Ctrl-C cancels the run and stops its agents.
indra run without a name lists the configured workflows.
The Workflows page also has fan-out: one prompt across any set of targets on any nodes, in parallel within each node's capacity. That is the quickest way to review ten repositories spread over three machines.
How remote execution stays safe
- SSH does the transport. No daemon, no open port: authentication, encryption and host-key
checking are SSH's. indra calls
sshwithBatchMode=yes, so it never prompts and never accepts an unknown host key. - Nothing you type reaches a remote shell. The only remote command is the fixed
indra node exec; the job (runner name, prompt, target) travels as JSON on stdin. A prompt containing$(...)is just text. - The remote decides what runs.
indra node execruns only runners from its own configuration, by name, in a directory that must exist on it. The console can launch only into a node's configuredtargets. - Stop means stop. The hub keeps the job's stdin open for its lifetime; when it closes (Stop, cancel, or a dropped connection), the node terminates the agent's whole process tree.
- Destinations are validated, so a config value cannot smuggle in an ssh option such as
-oProxyCommand=....
Remote nodes need a POSIX shell (Linux, macOS, BSD). local works everywhere.
Review tooling
The deterministic parts of a review are subcommands of the same binary, so the indra plugin's skills need no interpreter on any platform:
| Command | What it does |
|---|---|
indra mem record-run <run_dir> |
Record a finished run's verified.jsonl into memory: findings, false positives as learnings, class and file hubs, chains-with links. A finding seen again (by fingerprint) gets a "seen again" line. Never copies evidence, payloads or secrets. --dry-run prints the indra mem commands instead. |
indra publish plan <run_dir> --to jira|github|confluence|notion |
The exact records to publish, with indra-fingerprint: markers for de-duplication; refuses if evidence or a payload would leak into one. |
indra review cache --skills <dir> get|put|changed|stat|prune|clear|key|kb-version |
The PR review cache. Keys hash every sh-kb-* knowledge base, the skill version and the model, and it fails closed: a stale "clean" never suppresses a finding. |
indra review metrics start|event|finish|report|path|purge |
Local-only run metrics (tokens, duration, model, cache reuse) in append-only JSONL; credentials are redacted before they are written. |
indra review post --repo R --pr N --payload P --verdict V |
Post the review and set the commit status through gh, recovering a 422 by moving comments into the body, and never leaving the status at pending. |
indra skills sync [--check], indra skills install --to agents|claude|opencode |
Keep the repository's skills/ mirror current, or install the skills where an agent looks for them. |
Maintainer commands: indra dev site builds the website (site/ plus the docs pages listed in
site/pages.toml, rendered from the Markdown, with a broken-link check), and indra dev dashes [--fix] finds em and en dashes.
The same, from the console
Every one of these is also in indra serve, calling the same code, so the command line and the
console always agree:
- A run's page (Runs, then a run) has Next steps when the run has a
verified.jsonlor a PR review payload: Record into memory (with a preview of the commands), Publish (pick the destination, lowest severity and whether to include needs-runtime findings; download or copy the plan for the sh-publish skill), and Post to GitHub for a PR review (a dry run first; the repository and PR number come from the run'srun.jsonand can be changed). - Tools shows the review cache (with prune and clear, per repository or all), the local
metrics (totals, per model, per run; drop old records or delete them), the skills (whether the
skills/mirror is current, sync it, install the skills for an agent, for you or in a target), and the maintainer chores (build the site into_site, look for dashes).
Writes are refused when the console is read-only; previews and dry runs are not. Posting runs
gh as you, on the machine running indra serve, after you confirm.
| Call | Same as |
|---|---|
GET /api/run/actions?key= |
what the run supports, its repository and PR |
POST /api/run/record {key, dry_run} |
indra mem record-run |
POST /api/run/publish {key, to, min_severity, statuses, max} |
indra publish plan |
POST /api/run/post {key, repo, pr, dry_run, no_status} |
indra review post |
POST /api/pr-review {pr, target, profile, tier, deep, fail_on, dry_run} |
starts /sh-pr-review |
GET /api/run/findings, POST /api/run/triage, GET /api/run/compare |
triage and comparison (see the guide) |
GET /api/tools |
indra review cache stat, indra review metrics report, indra skills sync --check |
POST /api/tools/cache {action: prune|clear, max_age_days, repo} |
indra review cache prune|clear |
POST /api/tools/metrics/purge {older_than_days} |
indra review metrics purge |
POST /api/tools/skills/sync, POST /api/tools/skills/install {to, scope, target} |
indra skills sync, indra skills install |
POST /api/tools/site, GET /api/tools/dashes |
indra dev site, indra dev dashes |
Development
# and orchestration over SSH (with a stand-in ssh client)
src/mem/ is the memory engine, src/deck/ the console, ui/ its front end (plain
JavaScript, embedded at build time), src/review.rs, src/publish.rs and
src/mem/record.rs the review tooling, src/skills.rs and src/site.rs the maintainer
tools. The tests start stand-in agents written in Python, so cargo test needs python3 on
Unix; nothing that ships does. Dependencies: axum, tokio, clap, serde,
serde_json, toml, getrandom (and libc on Unix).
MIT licensed, like the rest of this repository.