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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
<p align="center"><img src="../../assets/logo.svg" alt="" width="112"></p>

<h1 align="center">indra CLI</h1>

<p align="center">Graph memory and an agent web console, in one small binary.<br><a href="https://dmdhrumilmistry.github.io/indra/">Website</a> &middot; <a href="../../README.md">The indra harness</a></p>


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](#review-tooling). |
| `indra skills` | Sync the skills mirror, or install the skills for Claude Code, Codex or opencode. |

The [security harness](../../README.md) 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`):

```bash
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:

```bash
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:

```bash
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](https://github.com/dmdhrumilmistry/indra/releases) (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](https://rustup.rs) 1.89+):

```bash
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.)

```text
$ 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

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

```markdown
---
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](../../docs/guides/knowledge-base.md)) |

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:

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

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

```text
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](../../docs/guides/machines.md). |
| **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](../../docs/guides/context-and-learnings.md). |
| **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](../../docs/guides/review-loop.md). |
| **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](../../docs/guides/marketplace.md). |
| **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](../../docs/guides/plan-and-delegate.md). |
| **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](../../docs/guides/board.md). |
| **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](#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](#settings). |

The same picture without a browser:

```bash
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.

```toml
[[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 |

```toml
[[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](../../assets/screenshots/settings.png)

| 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](../../docs/guides/users-and-roles.md).
- **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](../../docs/guides/processes.md).

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

```toml
[[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](../../docs/guides/local-models.md). 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:

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

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

```bash
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:

```toml
[[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](../../docs/guides/pivoting.md).

### 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](../../docs/guides/plan-and-delegate.md). For local models in any role, see
[Local models with Ollama](../../docs/guides/local-models.md).

### 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](../../docs/guides/context-and-learnings.md).

### 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](../../docs/guides/local-models.md).

**`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`).

```text
$ 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:

```toml
[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](#machines-that-dial-in-no-ssh): one command
on that machine, no SSH. Machines you already reach over SSH can be listed instead:

```toml
[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:

```text
$ 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:

```bash
# 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
```

```powershell
# Windows (PowerShell)
$env:INDRA_JOIN="http://192.168.1.20:7878 ABCDE-FGHJK"; irm https://raw.githubusercontent.com/dmdhrumilmistry/indra/main/install.ps1 | iex
```

```bash
# 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](../../docs/guides/machines.md).

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](../../docs/guides/mdm.md).

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:

  ```toml
  [[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:

```toml
[[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:

```text
$ 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:

```toml
[[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:

```bash
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.

```toml
[[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.)

```toml
[[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.

```toml
[[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.

```toml
[[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.

```toml
[[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.**

```toml
[[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).

```toml
[[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.**

```toml
[[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:

```bash
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.

```toml
# 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](../../docs/guides/review-loop.md)) |
| `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

```bash
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.