indra 0.12.0

Run, watch and orchestrate AI coding agents (Claude Code, Codex, Gemini, Ollama) across machines: a web console, workflows with conditions and loops, boards, an MCP server and graph memory, in one binary.
indra-0.12.0 is not a library.

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):

curl -fsSL https://raw.githubusercontent.com/dmdhrumilmistry/indra/main/install.sh | sh   # Linux, macOS
irm https://raw.githubusercontent.com/dmdhrumilmistry/indra/main/install.ps1 | iex       # Windows (PowerShell)

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:

indra update --auto on    # off: back to just telling you

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:

indra completions --install          # your shell (bash, zsh, fish, PowerShell)
indra completions zsh > _indra       # or print the script for a shell and place it yourself
indra completions --uninstall        # undo

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+):

cargo install --locked indra
# or the newest code:
cargo install --locked --git https://github.com/dmdhrumilmistry/indra indra
# or, from a clone:
cargo install --locked --path tools/indra

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

indra mem init ~/notes                    # or let the first `add` create the default vault
export INDRA_VAULT=~/notes

indra mem add "SQL injection" -t vuln-class --alias SQLi -b "Untrusted input reaches a SQL interpreter."
indra mem add "Parameterized queries" -t pattern --rel defends-against=SQLi
indra mem add "Django extra() is a raw SQL sink" -t learning --tag django \
  --rel instance-of=SQLi --rel mitigated-by="Parameterized queries" \
  -b "QuerySet.extra(where=[...]) takes raw SQL. Any f-string into it is a SQLi sink."

indra mem recall "django raw sql"         # what do I know about this?
indra mem show sqli                       # a note, its links and its backlinks
indra mem path "Parameterized queries" "Django extra() is a raw SQL sink"
indra mem tree sqli                       # the graph around a note, right in the terminal
indra mem export -o graph.html            # optional: an interactive view in a browser

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

# Django extra() is a raw SQL sink

QuerySet.extra(where=[...]) takes raw SQL. See also [[orm-escape-hatches]].

## Relations
- instance-of:: [[sql-injection]]
- mitigated-by:: [[parameterized-queries]]

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

  1. Search. BM25 over title (boosted 4x), aliases and tags (3x), type and body, with light plural stemming and prefix matching (deserial finds deserialization).
  2. Spread. Each hit's normalised score flows along links in both directions for --depth hops (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 its via: explanation.
  3. Budget. The top -n notes are printed with their body (minus the title line and the raw Relations block, which is replaced by a links: line with resolved titles), within a total of --max-chars characters, 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. --upsert makes 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 with O_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 - item lists. Nested maps and multi-line strings are skipped rather than rejected.
  • Dependencies: clap, serde, serde_json. Nothing else.

Console: indra serve

cd ~/code/indra                       # or any project; see "Profiles" below
indra serve --target ~/code/my-api    # the codebase whose runs to watch and where agents run
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.

The menu keeps what you use every day at the top: Home (a short Get started checklist that ticks itself off), Run an agent, Sessions, Workflows, Board, Results, Machines and Knowledge. More opens the rest: Accounts, the harness's Agents, Skills and Prompts, the Graph map, the Marketplace, the Terminal and Tools. Run an agent asks for four things (what to do, the machine, the agent and the folder); profile, model, permissions and memory are under More options.

Page What it shows
Machines The computers agents run on: this one, and every machine that joined. Add a machine gives one command to paste on the other computer; the page then shows what joined (name, system, CPUs, agents installed). See Adding machines.
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.
Results Each run directory, live: stage progress, results per agent or class, status and severity counts, and every file the run wrote. A run with a threat model shows its trust boundaries (with the controls and gaps at each) and its threats ranked by risk, open ones first. 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 Run an agent: 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.
Sessions Your agents as conversations, like Claude Code online: the sessions on the left (search, + New, live status; kept across restarts of the console), the selected one in the middle (your messages, its replies, its tool steps folded to one line each, what it asks you with Allow and Deny, and a box to reply, queued while it works, or to continue it once it ended), and on the right where it works and what it did (machine, folder, git branch, agent and model, context used, tokens in and out with the share read from the prompt cache and the cost, its task list, the files it changed with their diffs, every other file in its folder to browse, and the pull requests it linked). Code shows with line numbers, in replies too. Table view is the older list of every process.
Run an agent, Running 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:

indra ls --defs        # harnesses, every agent/skill/prompt, recent runs with stage progress
indra check            # globs that match nothing, unknown stage agents, missing runners

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.

[[harness]]
name     = "research-agents"
root     = "."
agents   = ["agents/*.md"]
skills   = ["skills/*/SKILL.md"]
prompts  = ["prompts/**/*.md"]
memory   = ["{target}/.memory"]         # memory vaults; {target} expands per target
targets  = ["../project-a"]
runs     = ".runs/*"                    # one directory per run, under each target ({a,b} for alternatives)
findings = ["results.jsonl"]            # JSONL results inside a run
group_by = "agent"                      # which field names the agent that produced a result
stages   = [
  { name = "plan",     agent = "planner",    done_when = "plan.md" },
  { name = "research", agent = "researcher", done_when = "results.jsonl" },
  { name = "write",    agent = "writer",     done_when = "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
[[runner]]
name    = "claude-edits"
command = "claude"
args    = ["-p", "{prompt}", "--output-format", "stream-json", "--verbose", "--permission-mode", "acceptEdits"]
format  = "claude-stream-json"          # or "text"

[[preset]]                              # saved prompts on the Launch page
name    = "Nightly quick review"
harness = "indra"
runner  = "claude"
prompt  = "/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:

The Settings page: editing a workflow and its tasks

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 Run an agent (it 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 serve would 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.toml on 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 an HttpOnly, SameSite=Strict cookie or Authorization: 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 Host is not localhost, 127.0.0.1 or [::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 serve is 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 to logs/terminal.log in 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 strict Content-Security-Policy with 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) or bypassPermissions.
  • 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
[[profile]]
name            = "planner"
runner          = "claude"
model           = "opus"
permission_mode = "plan"

[[profile]]
name            = "worker"
runner          = "claude"
model           = "sonnet"
permission_mode = "acceptEdits"
allowed_tools   = ["Read", "Grep", "Glob", "Edit", "Bash(git diff:*)"]

[[profile]]
name     = "local"                   # Claude Code on a local model, through Ollama (0.14+)
runner   = "claude"
model    = "qwen3-coder"
provider = "ollama"                  # ollama_url = "http://gpu-box:11434" for another machine

[[profile]]
name              = "lead-local"     # Opus on Anthropic, its subagents on the local model
runner            = "claude"
model             = "opus"
subagent_model    = "qwen3-coder"
subagent_provider = "ollama"         # through indra's model router; no credential reaches Ollama

[[profile]]
name           = "lead"
runner         = "claude"
model          = "opus"
subagent_model = "haiku"             # Claude Code's own subagents run on Haiku (CLAUDE_CODE_SUBAGENT_MODEL)

[[profile]]
name    = "learner"
runner  = "claude"
memory  = true                       # start with the memory notes relevant to the prompt
reflect = 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:

[[profile]]
name = "board-worker"
runner = "claude"
env  = { JIRA_TOKEN = "secret:jira" }

Set the value in Settings > Secrets, or on a machine without a browser:

printf '%s' "$TOKEN" | indra secret set jira --description "Jira API" --profile board-worker
indra secret list          # names, scopes and use; never values
indra secret rm jira
  • Encrypted at rest in the indra data directory (secrets/vault.json, XChaCha20-Poly1305). The key is INDRA_SECRETS_KEY (64 hex characters) when set, so it can live off the disk; otherwise a random key in secrets/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).

Accounts and usage

By default nothing needs setting up: an agent uses the login its CLI already has on the machine it runs on (Claude Code logged in, codex login, ...), and indra counts what each of those logins spends. Accounts (the Accounts page; added under Settings > Accounts) give agents credentials from here instead:

[[account]]
name       = "work"
credential = "secret:anthropic-work"   # set it in Settings > Secrets (never the key itself)
kind       = "oauth_token"             # a Claude subscription token (`claude setup-token`); default api_key
daily_runs = 40                        # limits are optional: daily_usd, monthly_usd, daily_runs

[[account]]
name       = "team-api"
credential = "secret:anthropic-team"   # an API key
daily_usd  = 25

[[account]]
name       = "codex"
agent      = "codex"                   # claude (default) | codex | gemini | any runner (with env = "VAR")
credential = "secret:openai"

[routing]                              # all optional; these are the defaults
strategy          = "most_remaining"   # | round_robin | in_order (by priority)
fallback_to_login = true               # all used up: the machine's own login (false: refuse)
cooldown_minutes  = 60                 # rest after a usage limit, unless the agent names its reset
  • Credentials on every node. The credential goes in the agent's environment (ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN, OPENAI_API_KEY, GEMINI_API_KEY, or env), resolved on the hub and sent with the job, so a remote node needs no login of its own. It is redacted from everything the agent prints. A profile's own environment wins.
  • Routing. Each launch (Launch, workflows, the board, plans) picks an account for its agent and node: the one a profile pins (account = "work" on the profile, or "login" for the machine's own), else by strategy: most_remaining takes the account with the most left of its tightest limit, then the one used least today. An account can be kept to some nodes, or turned off with disabled. A local model (a profile on Ollama) uses no account.
  • Usage. Runs and cost (as agents report it: Claude Code reports each run's cost; subscriptions report none, so limit those by daily_runs) are counted per account and per machine login, by day and month, on the Accounts page and with indra accounts.
  • Usage limits. When an agent fails saying it hit a limit ("usage limit reached", "rate limit", 429, "insufficient quota"...), its account rests until the reset time the agent gives, or cooldown_minutes, and routing moves on; Let it run again on the Accounts page ends the rest. Each process page shows the account it ran with, and why routing chose it.

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 = true on a profile): the notes from the harness's vaults that best match the prompt (graph-aware indra 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 or indra attach, wait in Knowledge > Learnings with their source (process, session, profile, directory) until you accept one into a vault, as a learning note, 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:

[terminal]
shell  = "/bin/zsh"              # the default for new terminals
args   = ["-l"]                  # a login shell
shells = ["/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 other machines. A node is one such machine; local is always there. The easy way to add one is joining: one command on that machine, no SSH. Machines you already reach over SSH can be listed instead:

[local]
max_parallel = 4                         # agents at once on this machine

[[node]]
name         = "build-box"
ssh          = "ci@build-box.internal"   # host, user@host, or a Host alias from ~/.ssh/config
max_parallel = 2
targets      = ["/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, Machines 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. Run an agent 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 machine can join the hub instead of being reached over SSH. On the Machines page, click Add a machine and paste the command it shows on that machine. Nothing else to fill in:

# Linux or macOS: installs indra if needed, then joins
curl -fsSL https://raw.githubusercontent.com/dmdhrumilmistry/indra/main/install.sh | INDRA_JOIN="http://192.168.1.20:7878 ABCDE-FGHJK" sh
# Windows (PowerShell)
$env:INDRA_JOIN="http://192.168.1.20:7878 ABCDE-FGHJK"; irm https://raw.githubusercontent.com/dmdhrumilmistry/indra/main/install.ps1 | iex
# indra already installed
indra join http://192.168.1.20:7878 ABCDE-FGHJK   # optional: --name build-box --target /srv/api

indra join names the machine after its host name (taking name-2 if that is in use), reports its system, CPUs and installed agents, lets agents work in the folder it ran in, and keeps the link running in the background, started again at log-in or boot (a systemd user service on Linux, a launchd agent on macOS, a sign-in entry on Windows; --foreground stays in the terminal). The console shows the machine as soon as it joins. The folders agents work in are changed on its card (Change, with a folder browser that walks the machine's folders over its link); they need not be where indra is installed. On the machine, indra node status says which hub it joined and how its link runs, indra node folders lists or changes the folders agents may use, and indra node leave undoes it all. Remove on its card in the console disconnects it and deletes its key. The step-by-step guide, with troubleshooting, is Adding machines.

Many machines at once: Add many machines makes an enrollment key (valid up to a year, an optional machine limit, revocable; the hub keeps only its SHA-256) with ready-made scripts for Jamf, Intune and Linux. Organization-wide settings come from defaults.toml (under the user's) and managed.toml (enforced) in /Library/Application Support/indra, /etc/indra or C:\ProgramData\indra; a [join] section there lets indra join run with no arguments. See Rolling out with MDM.

Joining works when the console listens on the network (--bind 0.0.0.0:7878) even though its settings are then locked against browser edits: a joining machine adds only its name to indra.toml, never a command. A console started with --read-only or --lock-config refuses joins.

  • 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-config if you also want to edit settings remotely), ideally behind a TLS reverse proxy so the node uses https:///wss://.

  • Enrolment. The code works once, for an hour, and only an admin can make one. 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.json in 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:

    [[server.ice]]
    urls       = ["turn:turn.example.org:3478", "turns:turn.example.org:5349"]
    username   = "indra"
    credential = "secret:turn"   # resolved on the hub, sent to nodes over their link
    

    When there is no direct path, traffic is relayed over the WebSocket. [server] webrtc = false, or indra join --no-webrtc, keeps everything on the relay. The Machines 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 an ssh destination 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:

[[workflow]]
name = "nightly"
description = "Review the services on the build box and the web app here, then summarise."

[[workflow.task]]
id      = "api"
node    = "build-box"
target  = "/srv/api"
prompt  = "/indra full security review of {target}"
retries = 1                              # one more attempt if it fails

[[workflow.task]]
id     = "web"
node   = "local"                         # the default
target = "../web"                        # local targets are relative to indra.toml
prompt = "/indra full security review of {target}"

[[workflow.task]]
id     = "summary"
target = "."
prompt = "Summarise tonight's reviews and list new critical findings."
needs  = ["api", "web"]                  # runs only if both succeed
  • A task starts when everything it needs has 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 retries times.
  • Cancelling stops the running agents and cancels what has not started.
  • {target} and {node} are filled into the prompt; runner defaults to claude.
  • Workflows are checked when the config loads: unique task ids, known nodes, needs that 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:

[[workflow]]
name = "triage-pr"

[[workflow.input]]
name     = "pr"
label    = "Pull request"
required = true
pattern  = '[0-9]{1,9}'                  # text inputs may require a form
[[workflow.input]]
name = "target"
kind = "target"                          # one of the harness's targets
[[workflow.input]]
name    = "depth"
kind    = "choice"
options = [{ value = "", label = "auto" }, { value = "--tier=2", label = "standard" }]
[[workflow.input]]
name      = "dry_run"
kind      = "bool"
default   = "true"
when_true = "--dry-run"                  # what {input.dry_run} becomes when ticked
[[workflow.input]]
name = "profile"
kind = "profile"                         # a configured profile, or none

[[workflow.task]]
id      = "review"
target  = "{input.target}"
profile = "{input.profile}"
prompt  = "/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, three 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), security-review (codebase, optional focus such as since:main, profile) and threat-model (codebase, optional since:<ref> or design:<file>, profile). Run any workflow from Runs > Run a workflow or the Workflows page (both ask for its inputs), or from the terminal:

indra run pr-review --input pr=https://github.com/acme/shop/pull/7 --input target=$PWD --input depth=--tier=2
indra run                                # lists the workflows and their inputs (* required)

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.

Decisions, loops and schedules are drawn in the graph too:

  • If (from the palette) checks something and has a green Yes and a red No edge: drag each to the step that runs on that answer. Its question is picked from lists (a step before it succeeded, failed, its output contains a text, its exit code is a number, or an input is a value), or written by hand. A step both sides lead to runs after whichever side ran.
  • For each and Repeat until are dropped on a step. They show on it as a band ("for each: api, web", "repeat until its output contains PASS, at most 3"), Repeat with an arrow back to the step's start; click the band to change or remove the loop.
  • Click an arrow to choose when the next step runs: when this one succeeds, when it fails, or either way.
  • Click Start to schedule the workflow: only when started, every hour, every day, every weekday or every week at a time, or any cron schedule. Start then shows it ("weekdays 09:00"), and so does the workflow's card.

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.

[[workflow.task]]
id     = "recon"
skill  = "sh-security-review"
prompt = "{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.)

[[component]]
name    = "review"
prompt  = "/indra full security review of {target}"
profile = "worker"
retries = 1

[[workflow.task]]
id        = "api"
component = "review"
node      = "build-box"
target    = "/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.

[[workflow.task]]
id  = "test"
run = "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.

Schedules. schedule runs a workflow by itself while indra serve runs, in that computer's local time: standard cron (minute hour day month weekday), with ranges, lists, steps, names (mon, jan) and @hourly, @daily, @weekly, @monthly. Its inputs take their defaults, so give required ones a default; a run that cannot start is logged.

[[workflow]]
name     = "nightly"
schedule = "0 2 * * 1-5"                 # 02:00 on weekdays

If steps. An If step runs nothing: it checks a condition and answers yes or no. A step after it says which answer it follows with branch; the other side is skipped (without failing the workflow), and a step that both sides lead to runs after the side that ran.

[[workflow.task]]
id    = "passed"
needs = ["test"]
if    = "test.output contains 'test result: ok'"

[[workflow.task]]
id     = "ship"
needs  = ["passed"]
branch = "yes"
run    = "make release"

[[workflow.task]]
id      = "fix"
needs   = ["passed"]
branch  = "no"
profile = "worker"
prompt  = "The tests fail:\n{steps.test.output}\nFix the code."

[[workflow.task]]
id    = "report"
needs = ["ship", "fix"]                  # after whichever side ran
run   = "echo done"

Conditions and failure handling.

[[workflow.task]]
id            = "test"
run           = "cargo test 2>&1"
allow_failure = true                     # a failure here does not fail the workflow

[[workflow.task]]
id      = "fix"
needs   = ["test"]
when    = "test.failed and input.autofix == 'true'"
profile = "worker"
prompt  = "The tests fail:\n{steps.test.output}\nFix the code."

[[workflow.task]]
id    = "tell"
needs = ["fix"]
on    = "failure"                        # only if something it needs failed
run   = "echo \"fix failed: $INDRA_OUTPUT_FIX\" | mail -s ci team@example.com"

[[workflow.task]]
id    = "clean"
needs = ["test", "fix"]
on    = "always"                         # once they finish, however they went
run   = "rm -rf target/tmp"
  • when is a condition on the step itself (the If step above is the same, drawn in the graph): a condition on earlier steps and inputs: <step>.succeeded, .failed, .skipped, .status, .output, .exit_code, and input.<name>, with ==, !=, contains, <, <=, >, >=, and, or, not and parentheses. False: the step is skipped. A step it reads must run before it (in its needs, directly or through others), so its result is known.
  • on: success (the default: every step it needs succeeded, or failed with allow_failure), failure (one of them failed) or always.
  • 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).

[[workflow.task]]
id     = "nothing-to-fix"
needs  = ["test"]
when   = "test.succeeded"
stop   = "success"                       # or "failure"
prompt = "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.

[[workflow.task]]
id      = "lint"
foreach = ["api", "web", "worker"]       # or "{input.services}": commas or new lines
run     = "make -C services/$INDRA_ITEM lint"

[[workflow.task]]
id       = "make-green"
prompt   = "Run the tests and fix what fails."
until    = "self.output contains 'all tests pass'"
max_runs = 3                             # default 3, at most 20
  • foreach makes 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_ITEM in a command. Whatever needs the step waits for every copy. At most 50 items; none, and the step drops out.
  • until runs the step again after each success until the condition holds (self.output, self.exit_code, or other steps), at most max_runs times, then fails. A failed run uses retries as 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:

claude mcp add indra -- indra mcp        # then: "make me a workflow that ..."

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
[[workflow]]
name = "triage"

[[workflow.task]]
id      = "recon"
skill   = "sh-security-review"
prompt  = "{target} stage:recon"
profile = "planner"                      # a built-in profile, so it works for everyone

[[workflow.task]]
id        = "lint"
component = "lint"                       # a saved task kept in the same file
needs     = ["recon"]

[[component]]
name   = "lint"
prompt = "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 as indra.toml.
  • In the editor, Saved in chooses between this machine's indra.toml and 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.toml with 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 ssh with BatchMode=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 exec runs 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 configured targets.
  • 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.jsonl or 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's run.json and 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

cd tools/indra
cargo test            # unit tests, API security tests, end-to-end tests of `indra mem`,
                      # and orchestration over SSH (with a stand-in ssh client)
cargo clippy --all-targets -- -D warnings
cargo fmt --check
cargo run -- serve --target /path/to/project

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.