apexe 0.6.0

Outside-In CLI-to-Agent Bridge
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
# apexe User Manual

| Field | Value |
|-------|-------|
| **Version** | 0.6.0 |
| **Date** | 2026-07-28 |
| **Platform** | macOS / Linux |

---

## Table of Contents

1. [Introduction](#1-introduction)
2. [Installation](#2-installation)
3. [Quick Start](#3-quick-start)
4. [Commands Reference](#4-commands-reference)
5. [Configuration](#5-configuration)
6. [Scanning Engine](#6-scanning-engine)
7. [Schema Generation](#7-schema-generation)
8. [Behavioral Annotations](#8-behavioral-annotations)
9. [Governance](#9-governance)
10. [MCP Server](#10-mcp-server)
11. [A2A Server](#11-a2a-server)
12. [Integrating with AI Agents](#12-integrating-with-ai-agents)
13. [Error Handling & AI Guidance](#13-error-handling--ai-guidance)
14. [File Locations](#14-file-locations)
15. [Logging & Debugging](#15-logging--debugging)
16. [Troubleshooting](#16-troubleshooting)

---

## 1. Introduction

**apexe** turns any CLI tool on your system into a governed, schema-enforced service that AI agents can invoke safely via the MCP protocol. It works in three steps:

1. **Scan** — Deterministically extract commands, flags, and arguments from CLI tools (no LLM required).
2. **Govern** — Classify commands as readonly/destructive, generate ACL rules, enable audit logging.
3. **Serve** — Expose tools via MCP (stdio for Claude Desktop/Cursor, HTTP for remote agents).

apexe is built on the [apcore](https://github.com/aiperceivable/apcore-rust) ecosystem: apcore (core types), apcore-toolkit (output), apcore-mcp (MCP server), apcore-a2a (A2A agent server), apcore-cli (audit logging).

---

## 2. Installation

### Prerequisites

- **Rust** 1.75 or later (uses async fn in traits)
- **Cargo** (included with Rust)
- macOS or Linux

### Install from source

```bash
git clone https://github.com/aiperceivable/apexe.git
cd apexe
cargo install --path .
apexe --version
```

---

## 3. Quick Start

See [Quick Start Guide](quickstart.md) for the fastest path to a working setup.

```bash
apexe scan git curl grep         # scan tools
apexe list                       # verify modules
apexe serve                      # start MCP server (stdio)
```

---

## 4. Commands Reference

### 4.1 `apexe scan`

Scans one or more CLI tools and generates `.binding.yaml` files + ACL rules.

```
apexe scan <TOOLS>... [OPTIONS]
```

| Argument / Option | Default | Description |
|-------------------|---------|-------------|
| `<TOOLS>...` | (required) | CLI tool names to scan (must be on `$PATH`) |
| `--output-dir <DIR>` | `~/.apexe/modules/` | Directory to write binding files |
| `--depth <N>` | `2` | Subcommand recursion depth (1-5). `git remote add` = depth 2 |
| `--no-cache` | off | Force fresh scan, bypass cache |
| `--format <FMT>` | `table` | Output format: `json`, `yaml`, or `table` |
| `--skills-dir <DIR>` | - | Also write a Claude Skill (`SKILL.md`) per module under `<DIR>/.claude/skills/<module_id>/` |
| `--overlay <PATH>` | - | Load one explicit curated overlay file (JSON/YAML). See [§6.5 Tool Overlays](#65-tool-overlays) |
| `--verify` | off | Fail the command when a written binding does not verify. The YAML verifier runs either way; without this a failure is a warning and the scan still exits 0 |
| `--dry-run` | off | Report what would be written — bindings, ACL and skills — without creating or overwriting anything |

```bash
apexe scan git                         # basic scan
apexe scan ls jq curl                  # multiple tools
apexe scan git --depth 3               # deeper subcommand discovery
apexe scan git --no-cache              # force re-scan
apexe scan git --format json           # JSON output
apexe scan git --skills-dir ./out      # also write .claude/skills/cli.git.*/SKILL.md
apexe scan ls --overlay ~/.apexe/overlays/ls-gnu.json  # apply a curated overlay
```

### 4.2 `apexe serve`

Starts an MCP server exposing scanned tools to AI agents.

```
apexe serve [OPTIONS]
```

| Option | Default | Description |
|--------|---------|-------------|
| `--transport <TYPE>` | `stdio` | Transport: `stdio`, `http`, or `sse` (`sse` is deprecated upstream — see §10) |
| `--host <HOST>` | `127.0.0.1` | Host for HTTP/SSE transports |
| `--port <PORT>` | `8000` | Port for HTTP/SSE transports (1-65535) |
| `--explorer` | off | Enable the browser-based Tool Explorer UI. HTTP/SSE only — on stdio it warns and mounts nothing, since there is no HTTP surface to serve it on |
| `--modules-dir <DIR>` | `~/.apexe/modules/` | Directory containing binding files |
| `--name <NAME>` | `apexe` | MCP server name |
| `--show-config <TARGET>` | - | Print an integration snippet for `claude-desktop` or `cursor` and exit — see [§12](#12-integrating-with-ai-agents). Any other value is an error on stderr with a non-zero exit |
| `--prefix <PREFIX>` | - | Serve only modules whose id starts with `<PREFIX>` — excluded modules are not callable either |
| `--tags <TAGS>` | - | Serve only modules carrying every listed tag (comma-separated, AND) |
| `--acl <PATH>` | - | Path to ACL policy YAML file (the per-caller boundary) |
| `--auth <MODE>` | per-transport | `token` (default for HTTP/SSE), `jwt`, or `none`. Ignored for stdio |
| `--auth-token <VALUE>` | `APEXE_AUTH_TOKEN` | Bearer token for `--auth token`; one is generated and written to **stderr** at startup if unset |
| `--jwt-secret <VALUE>` | `APEXE_JWT_SECRET` | Signing secret for `--auth jwt` |
| `--allow-unauthenticated-bind` | off | Acknowledge `--auth none` on a non-loopback bind (otherwise refused) |
| `--allow-deprecated-sse` | off | Accepted and ignored; the defect it acknowledged was fixed in apcore-mcp 0.18. Hidden from `--help`, removal planned |
| `--enable-approval` | off | Prompt the connected MCP client for a human decision on every `requires_approval` module; a client that cannot be prompted is refused — see §9.6 |
| `--no-logging` | off | Disable structured logging middleware entirely |
| `--no-log-arguments` | off | Drop `inputs`/`output` from every log event, error records included; failures keep a payload-free record |
| `--no-circuit-breaker` | off | Disable CircuitBreakerMiddleware (on by default) |
| `--no-retry` | off | Disable RetryMiddleware (on by default; only ever retries idempotent timeouts) |
| `--metrics` | off | Enable `/metrics` (Prometheus) + `/usage` (JSON) — HTTP/SSE only |

```bash
apexe serve                                        # stdio (Claude Desktop/Cursor)
apexe serve --transport http --port 8000            # HTTP server
apexe serve --transport http --explorer             # HTTP + browser UI
apexe serve --show-config claude-desktop            # print integration config
apexe serve --transport http --metrics               # + /metrics and /usage
apexe serve --no-circuit-breaker --no-retry           # disable resilience middleware
```

### 4.3 `apexe a2a`

Starts an A2A agent server exposing scanned tools, sharing governance (ACL, logging, approval) with `apexe serve` via the same `Executor`.

```
apexe a2a [OPTIONS]
```

| Option | Default | Description |
|--------|---------|-------------|
| `--url <URL>` | `http://127.0.0.1:8000` | Base URL to bind the A2A server to |
| `--modules-dir <DIR>` | `~/.apexe/modules/` | Directory containing binding files |
| `--name <NAME>` | `apexe` | A2A agent name |
| `--explorer` | off | Enable browser-based Explorer UI |
| `--acl <PATH>` | - | Path to ACL policy YAML file |
| `--tags <T1,T2>` | - | Serve only modules carrying every listed tag. Excluded modules are neither advertised nor callable |
| `--prefix <PREFIX>` | - | Serve only modules whose ID starts with this prefix. Excluded modules are neither advertised nor callable |
| `--no-logging` | off | Disable structured logging middleware entirely |
| `--no-log-arguments` | off | Drop `inputs`/`output` from every log event, error records included; failures keep a payload-free record |
| `--no-circuit-breaker` | off | Disable CircuitBreakerMiddleware (on by default) |
| `--no-retry` | off | Disable RetryMiddleware (on by default; only ever retries idempotent timeouts) |
| `--execution-timeout <SECS>` | `300` | Per-task execution timeout in seconds |
| `--cors-origin <ORIGIN>` | - | Allowed CORS origin (repeatable) |
| `--allow-unauthenticated-bind` | off | Acknowledge a non-loopback `--url` (otherwise refused). A2A has no authenticator, so there is no credential to opt into |

> **No `--enable-approval` on `apexe a2a`.** A2A has no interactive elicitation
> transport, so an approval prompt can never be resolved over it; the flag would
> only ever error. Approval on A2A is a library-only feature — construct
> `A2aServerBuilder` with an `ApprovalStore`. `apexe serve` keeps
> `--enable-approval`, which prompts the connected MCP client for a human
> decision — see §9.6.

> **`apexe a2a` has no transport authentication.** The `--auth*` flags are
> `apexe serve` only. Bind A2A to loopback, or put it behind a reverse proxy
> that authenticates. Because there is no credential to grant or
> withhold, `--prefix`/`--tags` carry more weight here than on `apexe serve`:
> narrowing the registered surface is the only mechanism that limits what an
> unauthenticated caller can reach, short of an `--acl` keyed on an identity
> A2A never establishes.

```bash
apexe a2a                                       # http://127.0.0.1:8000
apexe a2a --url http://127.0.0.1:9000 --explorer  # custom port + browser UI
# A non-loopback bind needs the acknowledgement, because A2A has no authenticator:
apexe a2a --url http://0.0.0.0:9000 --allow-unauthenticated-bind
apexe a2a --acl ~/.apexe/acl.yaml               # governed by an ACL policy
apexe a2a --prefix cli.git.                     # serve only the git modules
apexe a2a --tags readonly                       # serve only readonly modules
```

### 4.4 `apexe list`

Lists all registered modules from binding files.

```
apexe list [OPTIONS]
```

| Option | Default | Description |
|--------|---------|-------------|
| `--format <FMT>` | `table` | Output format: `table` or `json` |
| `--modules-dir <DIR>` | `~/.apexe/modules/` | Directory to read binding files from |

### 4.5 `apexe config`

Shows or initializes apexe configuration.

```
apexe config [OPTIONS]
```

| Option | Description |
|--------|-------------|
| `--show` | Print resolved configuration as YAML |
| `--init` | Create default config at `~/.apexe/config.yaml` |

---

## 5. Configuration

Configuration resolves in 4 tiers (highest priority wins):

```
CLI flags  >  Environment variables  >  Config file  >  Defaults
```

### Config file

Located at `~/.apexe/config.yaml`. Create with `apexe config --init`.

```yaml
modules_dir: ~/.apexe/modules
cache_dir: ~/.apexe/cache
audit_log: ~/.apexe/audit.jsonl
log_level: info
default_timeout: 30
scan_depth: 2
json_output_preference: true
```

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `modules_dir` | path | `~/.apexe/modules` | Binding file storage |
| `cache_dir` | path | `~/.apexe/cache` | Scan result cache |
| `audit_log` | path | `~/.apexe/audit.jsonl` | Audit trail file |
| `log_level` | string | `info` | Log level: error, warn, info, debug, trace |
| `default_timeout` | integer | `30` | CLI subprocess timeout (seconds) |
| `scan_depth` | integer | `2` | Default subcommand recursion depth |
| `json_output_preference` | boolean | `true` | Prefer JSON output from CLI tools when available |

### Environment variables

| Variable | Overrides | Example |
|----------|-----------|---------|
| `APEXE_MODULES_DIR` | `modules_dir` | `/opt/apexe/modules` |
| `APEXE_CACHE_DIR` | `cache_dir` | `/tmp/apexe-cache` |
| `APEXE_LOG_LEVEL` | `log_level` | `debug` |
| `APEXE_TIMEOUT` | `default_timeout` | `120` |
| `APEXE_SCAN_DEPTH` | `scan_depth` | `3` |

---

## 6. Scanning Engine

apexe uses a three-tier deterministic scanning engine. No LLM is involved.

### Tier 1: `--help` Parsing

Runs `<tool> --help` and auto-detects the help format. Six built-in parsers, tried in the order below:

| Parser | Detects | Examples |
|--------|---------|---------|
| **Man** | A `--help` that is actually a man page (`NAME` + `SYNOPSIS` at column 0) | every `git <subcommand>` — `git log --help` runs `man git-log` |
| **BSD Usage** | Single-line bundled usage (BSD/macOS built-ins that reject `--help`) | ls, cat, chmod, sort (macOS) |
| **GNU** | Standard GNU-style help | ls, grep, curl, git (GNU/Linux) |
| **Click** | Python Click / argparse | aws, pip |
| **Cobra** | Go Cobra framework | kubectl, docker, gh |
| **Clap** | Rust Clap framework | ripgrep, fd, bat |

Extracts: subcommands, flags (long/short), positional args, types, defaults, enum values, descriptions. Each extracted flag also records a `confidence` level (`verified` > `high` > `medium` > `low`) reflecting how many independent sources (parsers, man page, overlay) agree on it.

### Tier 2: Man Page Enrichment

Parses `man <tool>` output — both GNU (`OPTIONS` section) and BSD (options listed inside `DESCRIPTION`) layouts:

- **DESCRIPTION section**: Enriches commands that have sparse descriptions (< 20 chars).
- **OPTIONS section**: Contributes flags directly to `global_flags` (not just enrichment) — this is what makes tools whose `--help` is a single bundled usage line (most BSD/macOS built-ins) scan to a full flag list instead of zero.
- **EXAMPLES section**: Hand-written invocations are extracted into `ScannedCLITool.examples` / `CommandContract.examples` — the only human-reviewed usage a scan can reach, since flag parsing alone can't say which flag combinations make sense together.

### Tier 3: Shell Completion Discovery

Parses zsh/bash completion scripts from standard paths:
- `/usr/share/zsh/functions/Completion/_<tool>`
- `/usr/local/share/zsh/site-functions/_<tool>`
- `/etc/bash_completion.d/<tool>`

Discovers subcommands that Tier 1 missed and merges them into the result (added as stubs with a warning).

### Subcommand Discovery

For tools with subcommands, apexe recursively runs `--help` on each subcommand up to `--depth` levels. For example, with `--depth 2`:

```
git --help          → discovers: commit, push, remote, ...
git remote --help   → discovers: add, remove, show, ...
```

### Caching

Scan results are cached in `~/.apexe/cache/`. Cache entries are keyed by tool name + **variant** + version (`<name>@<variant>_<version>.scan.json`), so a machine with both BSD `/bin/ls` and Homebrew's GNU `ls` caches them separately instead of one overwriting the other. Use `--no-cache` to force a fresh scan.

### 6.4 Tool Variant Detection

Every scan probes the binary (`<binary> --version`) and classifies it into `ScannedCLITool.variant`:

| Variant | Example |
|---------|---------|
| `bsd` | macOS system `/bin/ls`, `/usr/bin/grep` |
| `gnu` | Linux coreutils, Homebrew GNU tools on macOS |
| `apple` | Apple-authored ports (`sort`, `git` shipped with Xcode CLT) |
| `busybox` | BusyBox-based Linux (Alpine, embedded) |
| `unknown` | Version probe inconclusive |

The same command name can be a different program depending on the host, and BSD/GNU/Apple builds of the same tool frequently expose different flag sets — variant detection is what lets a scan (and a matching overlay, see below) pick the right one.

### 6.5 Tool Overlays

An overlay is a curated, human-reviewed description of one tool variant, keyed by `(command, variant, version_range)`. 42 ship built in, covering the 21-command POSIX core (`cat chmod cp cut df diff du find grep head ln ls mkdir mv rm sort tail touch uniq wc xargs`) across their BSD/GNU/Apple variants.

- **`mode: authoritative`** replaces the scan result for that command entirely.
- **`mode: merge`** keeps the scan as the base and only overrides the flags the overlay declares — a gap in the overlay degrades to the scanner's answer instead of erasing a real flag.
- **`confidence: verified`** requires a `provenance` block (platform, version, source document, date) recording how the overlay was checked; the schema rejects a `verified` overlay without it.
- Overlays are the only source that can express `conflicts_with` (mutually exclusive flags) and `long_running` (a flag that may block indefinitely, e.g. `tail -f`) — no `--help`/man format expresses either machine-readably.
- Overlays can also override behavioral annotations (`readonly`/`destructive`/`idempotent`/`requires_approval`) for a specific command.

Load one explicit overlay with `apexe scan <tool> --overlay <PATH>` (JSON or YAML), or install multiple overlays by dropping files under `~/.apexe/overlays/`. The format is defined by `schemas/tool-overlay.schema.json`. See [`docs/overlays.md`](overlays.md) for the full authoring and verification procedure — writing a `verified` overlay from memory instead of a real installation is exactly what it warns against.

---

## 7. Schema Generation

Each scanned flag/argument becomes a JSON Schema property.

### Type Mapping

| CLI Type | JSON Schema | Example |
|----------|-------------|---------|
| String | `"type": "string"` | `--message "hello"` |
| Integer | `"type": "integer"` | `--count 5` |
| Float | `"type": "number"` | `--ratio 0.5` |
| Boolean | `"type": "boolean"` | `--verbose` |
| Path | `"type": "string", "format": "path"` | `--config /etc/app.yaml` |
| URL | `"type": "string", "format": "uri"` | `--url https://...` |
| Enum | `"type": "string", "enum": [...]` | `--format json\|yaml\|table` |

### Special handling

- **Required flags**: Added to the schema's `required` array.
- **Repeatable flags** (`--include a --include b`): Wrapped as `"type": "array", "items": {...}`.
- **Default values**: Included with type-correct coercion (`"10"` becomes `10` for integers).
- **Boolean defaults**: `false` unless explicitly set.
- **Format hints**: `Path` and `URL` types emit `"format"` so AI agents can distinguish paths from plain strings.

### Output Schema

Tools with detected JSON output flags get an enhanced output schema:

```json
{
  "type": "object",
  "properties": {
    "stdout": { "type": "string" },
    "stderr": { "type": "string" },
    "exit_code": { "type": "integer" },
    "json_output": { "type": "object" }
  }
}
```

---

## 8. Behavioral Annotations

apexe automatically infers behavioral annotations from command names and flags.

### Command Name Patterns

| Annotation | Trigger Patterns |
|------------|-----------------|
| **readonly** | list, ls, show, get, status, info, version, help, describe, view, cat, log, diff, search, find, check, inspect, display, print, whoami, env, top, ps |
| **destructive** + **requires_approval** | delete, rm, remove, destroy, purge, drop, kill, prune, clean, reset, format, wipe, erase |
| **idempotent** | get, list, show, status, info, describe, version, help, check |
| **cacheable** | (readonly AND idempotent) |

### Flag Boosting

Certain flags escalate the annotation regardless of command name:

| Flags | Effect |
|-------|--------|
| `--force`, `-f`, `--hard`, `--recursive`, `-r`, `--all`, `--prune`, `--no-preserve-root`, `--cascade`, `--purge`, `--yes`, `-y` | `requires_approval = true` |
| `--dry-run`, `--check`, `--diff`, `--noop`, `--simulate`, `--whatif`, `--plan` | `idempotent = true` |

**Example**: `git push` has flag `--force`, so it gets `requires_approval = true` even though "push" is not in the destructive list.

### Risk / `open_world`

Risk is derived from annotations plus an `open_world` signal — the executable itself (`curl`, `wget`, `ssh`, `scp`, `rsync`, …) or a networked subcommand of an otherwise local tool (`push`, `pull`, `fetch`, `clone`, `deploy`, `login`, …). Precedence when a command matches more than one: `destructive` > `open-world` > `readonly`. This is name-based, so it's a floor rather than a guarantee — an overlay's `annotation_overrides` is the way to assert the truth for a specific tool that doesn't fit the pattern.

---

## 9. Governance

### 9.1 Access Control (ACL)

`apexe scan` automatically generates `~/.apexe/acl.yaml` using a **default-deny** model:

| Module type | Default rule |
|-------------|-------------|
| Readonly modules | `effect: allow` |
| Destructive modules | `effect: deny`, unconditional (no `conditions:` key) |
| All others | Default deny (no explicit rule) |

ACL format (editable):

```yaml
default_effect: deny
rules:
  - callers: ["*"]
    targets: ["cli.git.status", "cli.git.log", "cli.git.diff"]
    effect: allow
    description: "Auto-allow readonly git commands"
  - callers: ["*"]
    targets: ["cli.git.push"]
    effect: deny
    description: "Block destructive git commands"
```

> **A deny rule must be unconditional to deny.** apcore registers exactly five
> condition keys — `identity_types`, `roles`, `max_call_depth`, `$or`, `$not`.
> Any other key is treated as *unsatisfied*, so the rule never matches and the
> call falls through to the next rule or to `default_effect`. Earlier versions
> of this manual showed the destructive-deny rule carrying
> `conditions: {require_approval: true}`; copied verbatim under
> `default_effect: allow`, that rule denies nothing and the destructive command
> runs. apexe logs the reason when it happens:
>
> ```
> WARN apcore::acl: Unknown ACL condition 'require_approval' — treated as unsatisfied
> ```
>
> The `~/.apexe/acl.yaml` that `apexe scan` generates has always been correct;
> only the manual was wrong. There is **no ACL condition that means "ask a
> human first"** — approval is a separate layer (§9.6), not an ACL condition.

Rule ordering is **first match wins**, not most-specific-wins: an
`allow` rule for `cli.*` placed before a `deny` rule for `cli.rm` lets `cli.rm`
through. Put the narrow denials above the broad allows.

**A denial reads very differently on the two transports.** Over MCP the caller
gets the real reason:

```
[ACLDenied] Access denied: caller 'None' cannot access module 'cli.cp'
```

Over A2A it gets a JSON-RPC `-32001` with the fixed message `Task not found`,
and the task lands in `TASK_STATE_FAILED`. That mapping is apcore-a2a's, shared
with its Python and TypeScript siblings and locked by an upstream test: it
withholds the reason so an unauthorized caller cannot enumerate what exists,
the same reasoning that returns HTTP 404 instead of 403.

Know it before you debug an ACL over A2A. An agent reading `Task not found`
concludes its *task id* was wrong — the one thing that was fine — and an
operator watching it concludes the ACL is broken and removes it. The denial is
working; only the message is uninformative. `audit.jsonl` records the real
decision on both transports (§9.2), with `decision: deny`, the matched rule
index and the same `trace_id` the caller saw, so check there rather than trying
to read the outcome off the A2A response.

### 9.2 Audit Trail

`~/.apexe/audit.jsonl` records both the calls that ran and the calls that were
refused. Discriminate on `event`; `trace_id` joins a record to the corresponding
`tracing` line and to the ACL entries below.

A call that reached the wrapped binary — `event: "execution"`:

```json
{
  "timestamp": "2026-08-20T01:24:04.535Z",
  "event": "execution",
  "trace_id": "a09dfb2ab24d4faab69b222c52aac5e4",
  "caller_id": "@external",
  "module_id": "cli.cp",
  "status": "success",
  "exit_code": 0,
  "duration_ms": 3
}
```

A call the governance stack stopped — `event: "refusal"`. `error_code` replaces
`exit_code`, since nothing ever ran to exit:

```json
{
  "timestamp": "2026-08-20T01:20:34.532Z",
  "event": "refusal",
  "trace_id": "71a7bbc6e5f44437a97441952755d9a5",
  "caller_id": "@external",
  "module_id": "cli.cp",
  "status": "refused",
  "error_code": "APPROVAL_DENIED",
  "duration_ms": 0
}
```

- **Refusals are recorded regardless of `--no-logging`.** That flag governs the
  `tracing` stream; turning the operational log down must not turn the audit
  trail off. A caller probing the argv guards produces one `refusal` row per
  attempt either way — the sequence an audit exists to capture.
- **`caller_id` is the authenticated principal**, or `@external` for an
  unauthenticated inbound request (apcore's canonical name for one). It is
  omitted entirely rather than guessed when no identity is attached at all.
  Note this is *not* apcore's `Context::caller_id`, which names the calling
  *module* in a nested chain and is `None` for every inbound request.
- **`duration_ms` is 0 for a refusal that short-circuited** ahead of the
  middleware phase — in practice the approval gate, since an ACL denial
  produces no apexe `refusal` row at all (see below). No clock had started.
- **No input values, hashed or otherwise.** Earlier versions wrote an
  `input_hash`; it was salted with random bytes that were then discarded, so
  nothing could ever be checked against it. A field that cannot be verified is
  not a privacy control, so it was dropped rather than kept for appearance.
- **Resilience**: audit logging never causes execution failures. Write errors
  are reported via tracing and the call proceeds.
- **Permissions**: the file is created `0600` — the mode is set through `OpenOptions::mode`, so it never exists world-readable even briefly, and an operator's own later `chmod` is left alone.

**A third shape appears in the same file.** ACL decisions are written by apcore
itself, not by apexe, and carry its richer entry — `decision`, `reason`,
`identity_type`, `roles`, `call_depth`, and an RFC 3339 timestamp with a
`+00:00` offset rather than `Z`:

```json
{
  "timestamp": "2026-07-30T01:24:35.300132+00:00",
  "caller_id": "@external",
  "target_id": "cli.ls",
  "decision": "deny",
  "reason": "default_effect",
  "identity_type": "external",
  "roles": [],
  "call_depth": 1,
  "trace_id": "36df7e553f354ffcb572e5b961ec4627"
}
```

apexe deliberately does **not** also emit a `refusal` row for an ACL denial: it
would double-count the same event with strictly less detail. A consumer counting
refusals should count `event == "refusal"` plus `decision == "deny"`. A call
that reached the wrapped binary and then failed — a timeout, a spawn failure,
an output overflow — is **not** a refusal: it appears once, as
`event: "execution"` with `status: "error"` and `exit_code: -1`.

> **Breaking change (unreleased).** The `user` and `input_hash` fields are gone,
> and `event`, `trace_id` and `caller_id` are new. `user` came from `getlogin()`,
> which returns the owner of the controlling terminal — under a service manager
> that is the terminal's owner or `root`, not whoever made the call, so it
> attributed every request to the wrong principal. Consumers keying on either
> removed field need updating.

### 9.3 Subprocess Isolation (always-on)

Every `CliModule` call runs through `execute_subprocess` (`src/module/executor.rs`), which applies isolation **unconditionally** — there is no `--sandbox` toggle and no "unsandboxed" mode. (This is not `apcore-cli`'s `Sandbox`, which expects the host binary to re-exec itself with an `--internal-sandbox-runner` subcommand and rediscover modules from `APCORE_EXTENSIONS_ROOT` — a model apexe's runtime-scanned CLI modules don't fit.)

- **Environment scrubbing**: the subprocess does **not** inherit apexe's full environment. The env is cleared and only a base allowlist is passed through (`PATH`, `HOME`, `USER`, `LOGNAME`, `SHELL`, `LANG`, `LC_*`, `TERM`, `TZ`, `TMPDIR`), so secrets in apexe's environment (API tokens, cloud credentials) can't leak to — or be surfaced by — a wrapped tool. File-based credentials under `$HOME` still work. (Per-tool credential-env passthrough is planned as an opt-in config knob.)
- **No shell**: arguments are passed as direct argv and handed to `execve` — no shell is spawned anywhere on the execution path, so shell metacharacters are inert data and pass through unchanged. (`curl --data '{"a":1}'` and every `jq` filter depend on that.) A caller-supplied value is rejected only for NUL and the five line terminators, which would corrupt the audit trail's framing, and for a leading `-`, which the wrapped tool would parse as an option the caller was not granted — see `CONTROL_CHARS` and `validate_argument_value` in `src/module/executor.rs`. The shell-metacharacter blacklist that remains (`BINDING_INJECTION_CHARS`) applies only to `json_flag` and the command path read out of a *binding file*, which is a build-time artifact rather than caller input.
- **Output cap**: stdout/stderr are each capped at 64 MiB (`executor::DEFAULT_MAX_OUTPUT_BYTES`). A command that exceeds it gets `stdout_truncated`/`stderr_truncated: true` in the result instead of exhausting memory.
- **Timeout kills the process**: the child is spawned with `kill_on_drop(true)`; when `--timeout`/`default_timeout` elapses, the subprocess is actually terminated rather than left running as an orphan.
- **stdin** is connected to `/dev/null`, so a tool that waits for input fails fast instead of hanging.

Stronger OS-level sandboxing (seccomp/landlock, namespaces, cgroup limits) is tracked as roadmap; v0.x relies on the governance stack (ACL + approval + preview) plus the process isolation above.

### 9.4 Preview (Dry-Run Prediction)

Destructive modules (`annotations.destructive == true`) implement apcore's `Module::preview()` hook. It does not attempt to predict the actual side effects of an arbitrary CLI binary (`apexe` has no way to know what `git push --force` will do to a remote) — it only surfaces the exact resolved command line, so an approver can see what they're about to allow. Readonly/non-destructive modules return no preview (nothing to predict). Reachable via apcore-mcp's `__apcore_module_preview` meta-tool.

### 9.5 Resilience Middleware

`build_executor` wires two middleware on by default (`--no-circuit-breaker`/`--no-retry` to disable, on both `apexe serve` and `apexe a2a`):

- **`HealthOnlyCircuitBreaker`** — short-circuits calls to a `(module_id, caller)` pair after repeated failures (default: ≥5 samples, ≥50% error rate), instead of letting every caller keep hammering a hanging or broken CLI tool. Auto-recovers after a cooldown window via a single probe call. Only outcomes that say the *wrapped binary* is unhealthy feed the window — spawn failure, timeout, signal death, internal error. Input-validation rejections, conflict refusals and governance decisions (ACL denial, approval denied/timeout/pending, cancellation, depth and frequency limits) do **not** count: the module was reachable and enforced its contract exactly as designed, and schema trial-and-error is the normal behaviour of an LLM caller. Counting those meant five malformed calls disabled a tool for every caller while a binary that failed every time kept its circuit closed.
- **`RetryMiddleware`** — retries a call after a transient failure, but *only* when the error is explicitly marked `retryable`. `CliModule` marks a timeout `retryable` **only when the module is annotated `idempotent`** (§8) — a killed non-idempotent command (e.g. `rm -rf` timing out mid-delete) is never auto-retried, since it may have partially applied its side effect.

### 9.6 Approval: `--enable-approval` prompts the connected client

`--enable-approval` gates every call to a module annotated `requires_approval`
on a human decision, delivered to the connected MCP client as an
`elicitation/create` request. Accept and the call runs; decline or cancel and it
is refused.

**It only works with a client that declared elicitation support** in its
`initialize` handshake. A client that did not cannot be prompted, so its gated
calls are refused — fail-closed, with a reason that says the prompt could not be
delivered and names what to use instead. Check your client before turning the
flag on: against one without elicitation, this is still an unconditional deny
gate over every `requires_approval` module.

This needs apcore-mcp **0.18 or later**. Earlier versions could not reach the
prompt at all: the `ElicitCallback` lives inside apcore-mcp's router, and an
`ApprovalHandler` built outside it — which is all a CLI entry point can build —
received only the JSON `Context`, whose `data` map holds the string
`"available"` rather than the callback, because no closure is a
`serde_json::Value`. 0.18 registers the callback per tool call and puts its
*id* in the context instead, which is a `Value`, so the handler can exchange it
for the live callback.

apexe wraps apcore-mcp's `ElicitationApprovalHandler` in its own `ApprovalGate`
for two things upstream cannot do from where it sits: every refusal is written
to the audit trail (§9.2) — the approval gate runs ahead of the middleware
phase, so nothing else in the stack observes it — and a refusal for want of a
prompt is reported with a remedy rather than as `Elicitation returned no
response`. A human's own "no" is passed through verbatim.

For a per-caller boundary that needs no human at all, use `--acl` (§9.2).

For approval flows where the approver isn't in the session (e.g. a Slack bot),
`apexe`'s `ExecutorOptions`/`McpServerBuilder`/`A2aServerBuilder` accept an
`Arc<dyn apcore_mcp::ApprovalStore>` — when set, approvals become non-blocking
(`StorageBackedApprovalHandler`): the call returns an `ApprovalPending` error
immediately, and a separate mechanism you build resolves the decision later via
`ApprovalStore::resolve`.

> **Known limitation.** `requires_approval` is derived from whether a tool's
> *help text* mentions a flag in `APPROVAL_FLAGS`, not from the arguments a
> caller actually sent. So `cli.git.log` is gated (because `git log` accepts
> `--all`) while `cli.ssh` and `cli.curl` are not. Evaluating the gate against
> the rendered argv is tracked separately.

There is no CLI flag for this — `apcore-mcp`'s `InMemoryApprovalStore` is documented as unsuitable for production (state isn't shared across process invocations, so a hypothetical `apexe approval resolve` command couldn't reach a running server's store anyway). Embed apexe as a library and supply your own persistent store (Redis, a database, etc.) to use this for real.

---

## 10. MCP Server

### Transport Options

| Transport | Use case | Command |
|-----------|----------|---------|
| **stdio** | Claude Desktop, Cursor (default) | `apexe serve` |
| **streamable-http** | Remote agents, browser UI | `apexe serve --transport http --port 8000` |
| **sse** | ⚠️ Deprecated upstream, but served | `apexe serve --transport sse --port 8000` |

> **`--transport sse` is deprecated, but it works again and no longer needs an acknowledgement.**
> apexe used to refuse it: apcore-mcp shared one process-global channel across
> every connection, so responses went round-robin to whichever stream was next
> and, with two clients connected, **one client received the other's tool
> output**. apcore-mcp 0.18 scopes a session per connection and emits the
> `event: endpoint` a spec-compliant MCP SSE client waits for, so the defect is
> gone and the refusal went with it. apexe requires `apcore-mcp = "0.18"`, so a
> build cannot quietly resolve back to the affected 0.17.
>
> SSE is still **deprecated upstream** and apexe warns at startup. Prefer
> `--transport http` (streamable HTTP) for anything new.
>
> `--allow-deprecated-sse` is still accepted so existing invocations keep
> parsing, but it decides nothing and is hidden from `--help`. It will be
> removed in a later release.

### Transport Authentication

The three transports have different trust boundaries, so they get different
defaults:

| Transport / bind | Default | Why |
|---|---|---|
| **stdio** | no auth | The boundary is the parent/child process relationship — whatever can spawn apexe already holds your privileges. A token adds nothing and would break every Claude Desktop / Cursor config. |
| **HTTP/SSE on `127.0.0.1`** | bearer token, **generated and written to stderr at startup** | Any local process can reach the port, including a page in your browser. You should not have to manage a secret for a local dev server. |
| **HTTP/SSE on any other host** | authentication required | apexe wraps arbitrary local binaries. An unauthenticated non-loopback bind is a remote-execution entry point for every executable on the host. |

> **apexe terminates no TLS.** On a non-loopback bind the bearer token or JWT
> travels in cleartext, and anything on the path can replay it to reach the same
> remote-execution surface the credential exists to close. apexe warns at
> startup — both through `tracing` and in the token notice on stderr, since the
> two can be filtered differently — but it cannot refuse: apexe behind a
> TLS-terminating reverse proxy is the correct deployment and looks identical
> from inside the process. Put one in front, or bind to `127.0.0.1` and reach it
> through an SSH tunnel.

```bash
apexe serve --transport http                              # token generated + printed
apexe serve --transport http --auth-token "$MY_TOKEN"     # or APEXE_AUTH_TOKEN
apexe serve --transport http --auth jwt --jwt-secret "$S" # or APEXE_JWT_SECRET
apexe serve --transport http --auth none                  # loopback only
apexe serve --transport http --host 0.0.0.0 --auth none \
            --allow-unauthenticated-bind                  # states that you mean it
```

A generated token is written **directly to stderr**, not through the log
pipeline. Two consequences worth relying on: it appears at every `--log-level`,
including `warn` and `error`, so the server can never demand a credential you
have no way to read back (`--show-config` omits credentials by construction);
and it is never written into a log file, journald, or a log aggregator you have
pointed `tracing` at. The log itself only records *that* token auth is on.

Clients send `Authorization: Bearer <token>`. The Explorer UI's `Authorization`
field is wired to this — browsing (GET) is open, execution (POST, including
`POST /explorer/tools/{tool}/call`) requires the credential.

`/health` is exempt so container and load-balancer probes work. `/metrics` is
**not** exempt: its `module_id` labels and per-module call volumes are
reconnaissance about what this host wraps and what actually gets used.

`--auth none` on a non-loopback bind refuses to start without the separate
`--allow-unauthenticated-bind` acknowledgement — a `--disable-*` flag gets
copied out of a tutorial once and then lives in everyone's startup script
forever.

### Built-in Middleware

| Middleware | Status | Effect |
|-----------|--------|--------|
| **LoggingMiddleware** | Enabled by default | Structured logging of inputs/outputs, redacting properties the scanner marked `x-sensitive` — see below |
| **FailureLogMiddleware** | Automatic with `--no-log-arguments`, and whenever an audit log is configured | One payload-free `ERROR` record per failed call, plus the `refusal` rows in `audit.jsonl` — see below |
| **CircuitBreakerMiddleware** | Enabled by default (`--no-circuit-breaker`) | Short-circuits a hanging/broken tool — see §9.5 |
| **RetryMiddleware** | Enabled by default (`--no-retry`) | Retries idempotent timeouts only — see §9.5 |
| **ApprovalGate** | Opt-in (`--enable-approval`) | Prompts the connected MCP client for a human decision on a `requires_approval` module; refuses when the client cannot be prompted — see §9.6 |

> **Credentials in tool arguments.** The logging middleware records each call's
> `inputs` and `output` at INFO. Redaction is schema-driven: the scanner marks
> credential-bearing options (`curl --user`, `--oauth2-bearer`, `--header`, key
> and certificate paths, and their equivalents) with `x-sensitive: true`, and
> those values are replaced before anything is written.
>
> **The marker lives in the binding file, so a binding scanned before the
> release that introduced it carries none and redacts nothing.** Redaction is not retroactive: it is
> the scanner that writes `x-sensitive`, and upgrading apexe does not rewrite
> bindings already on disk. Check with
>
> ```bash
> grep -L x-sensitive ~/.apexe/modules/*.yaml
> ```
>
> — every file listed still logs credentials verbatim. Re-scan those tools
> (`apexe scan <tool> --no-cache`) to pick the marker up, or run with
> `--no-log-arguments` until you have.
>
> A heuristic cannot be exhaustive over every wrapped tool's option set — a request body (`curl
> --data`) and a key sitting in a URL's query string announce themselves in no
> schema — so if you pass secrets through options apexe may not recognize, run
> with `--no-log-arguments`.
>
> `--no-log-arguments` drops the payload from **every** log event, the error
> record included. That matters because apcore's error record renders the same
> partially-redacted argument object as the `START` line, so a call rejected by
> schema validation used to print the body a successful call would have hidden.
> The operational record survives in a different shape: apexe installs its own
> `FailureLogMiddleware`, which emits one `ERROR` per failed call carrying
> `module_id`, `trace_id`, `caller_id`, `error_code` and `duration_ms` — and
> nothing the caller sent, not even the error message, since a validation
> message quotes the value it rejected. So a refusal is still visible, and
> `error_code` is what an alert keys on.
>
> `--no-logging` remains available to drop logging altogether, failure records
> included. The `audit.jsonl` trail records no raw argument values in any case.

### Observability

`apexe serve --transport http --metrics` enables two endpoints (HTTP/SSE only, ignored on stdio):

| Endpoint | Format | Content |
|----------|--------|---------|
| `/metrics` | Prometheus text | `apcore_module_calls_total`, `apcore_module_duration_seconds` histogram, per `module_id` |
| `/usage` | JSON | Per-module call count, error count, average latency, unique callers, trend |

### Tool Filtering

`--tags` and `--prefix` (and their `McpServerBuilder` equivalents) restrict
which scanned modules the server exposes. The filter is applied at
**registration** time, so an excluded module is not merely absent from
`tools/list` — it does not exist on this server at all, and `tools/call`,
`resources/list` and `resources/read` all return `ModuleNotFound` for it.

```bash
apexe serve --prefix cli.git            # a git-only server
apexe serve --tags readonly             # every listed tag must match (AND)
```

```rust
McpServerBuilder::new()
    .tags(vec!["readonly".to_string()])     // only expose readonly tools
    .prefix("cli.git")                       // only expose git tools
    .build()?;
```

This is coarse-grained: it selects a subset of the tool surface for everyone.
For per-caller rules, use `--acl` (§9.2).

> **A stray comma is refused, not applied.** `--tags readonly,` splits to
> `["readonly", ""]`, and every listed tag must match — no module carries an
> empty tag, so the filter would admit nothing and the server would start with
> an empty registry and no callable tools at all. `apexe serve` exits with an
> error naming the empty tag instead. A tag that is merely unknown to *this*
> host is not an error (one invocation is meant to be portable across
> differently scanned machines), but if the filter excludes every loaded
> module apexe logs a **warning** saying so, rather than the `admitted=0` info
> line that looked identical to an empty modules directory.

### Explorer UI

Enable with `--explorer` (HTTP transport only):

```bash
apexe serve --transport http --port 8000 --explorer
```

Provides a browser-based interface to explore available tools, view schemas, and test invocations.

> **The Try-It editor prefills only what a call needs.** It emits exactly the
> keys in the tool's `required` list — each with its declared `default` when the
> schema has one, and `null` otherwise — and omits every optional property. For
> `cli.curl`, whose contract has 257 properties, that is `{"url": null}` rather
> than 259 lines of blanks.
>
> A `null` placeholder is deliberately **not** valid against a typed property,
> so `Validate` refuses an untouched prefill and names the field you still have
> to fill. That is the point: an earlier version filled every string with `""`
> and every number with `0`, which satisfied both `required` and the declared
> types, so `Validate` certified an empty call as correct and `Execute` sent it
> — the wrapped tool was the first thing to object. Needs apcore-mcp 0.18.1 or
> later (`mcp-embedded-ui` 0.5).

### OpenAI Tools Export

Export tool definitions in OpenAI function calling format (programmatic API):

```rust
let tools = McpServerBuilder::new()
    .modules_dir("~/.apexe/modules")
    .export_openai_tools()?;
```

---

## 11. A2A Server

`apexe a2a` exposes the same scanned modules as an A2A agent via
[apcore-a2a](https://github.com/aiperceivable/apcore-a2a-rust), instead of
(or alongside) MCP. It shares governance with `apexe serve` — both build
their `Executor` through the same `apexe::module::build_executor()`, so an
`--acl` policy, the logging middleware, the audit trail, and the always-on
subprocess isolation behave identically regardless of which transport a caller
uses. **Approval** is the one exception: `apexe serve` offers
`--enable-approval`, which prompts the connected MCP client for a human
decision (§9.6), while `apexe a2a` has no such flag at all — A2A has no
elicitation transport, so a prompt could never be delivered over it — approval on A2A is
library-only (supply an `ApprovalStore` to `A2aServerBuilder`).
Transport authentication (§10) is likewise `apexe serve` only.

```bash
apexe a2a                                       # http://127.0.0.1:8000
apexe a2a --url http://127.0.0.1:9000 --explorer  # custom port + browser UI
# A non-loopback bind needs the acknowledgement, because A2A has no authenticator:
apexe a2a --url http://0.0.0.0:9000 --allow-unauthenticated-bind
apexe a2a --acl ~/.apexe/acl.yaml               # governed by an ACL policy
```

### Calling a skill: send a DataPart, not prose

Every apexe skill takes a JSON object — `build_input_schema` always produces
`"type": "object"` — so its arguments ride in a **DataPart**, and the skill's
`skillId` goes in the message metadata:

```bash
curl -X POST http://127.0.0.1:8000/ -H 'content-type: application/json' -d '{
  "jsonrpc": "2.0", "id": 1, "method": "message/send",
  "params": { "message": {
    "role": "user",
    "messageId": "m1",
    "metadata": { "skillId": "cli.cp" },
    "parts": [ { "kind": "data", "data": { "source_file": ["/tmp/a"], "target": "/tmp/b" } } ]
  } }
}'
```

A **TextPart carrying prose does not work**, and the error says why in terms of
the mechanism rather than the remedy:

```
TextPart text is not valid JSON: expected value at line 1 column 1
```

For an object-typed skill, apcore-a2a parses a TextPart's `text` *as* JSON — so
`{"kind": "text", "text": "{\"source_file\": [...]}"}` is accepted and
`{"kind": "text", "text": "copy this file"}` is not. That rule is shared with
apcore-a2a's Python and TypeScript siblings; a DataPart is the direct way to
say the same thing.

> **The agent card's per-skill `inputModes` is the field to read.** Each skill
> advertises `["application/json"]`, computed from its own schema. The card also
> carries an agent-level `defaultInputModes` of `["text/plain",
> "application/json"]` — that is apcore-a2a's hardcoded default and describes
> what the *framework* can accept, not this agent: apexe never produces a
> string-rooted skill, so `text/plain` is unreachable here. Per the A2A spec a
> skill's own `inputModes` overrides the default, so a client that reads it gets
> the right answer; one that reads only the agent default will try prose and hit
> the error above. apexe cannot narrow the default — `APCoreA2AConfig` exposes
> no field for it.

### Endpoints

| Endpoint | Purpose |
|----------|---------|
| `GET /.well-known/agent-card.json` | A2A Agent Card (skills derived from registered modules) |
| `GET /health` | Liveness check |
| `GET /explorer` | Browser-based Explorer UI (with `--explorer`) |

### Skill aliasing

Like MCP, A2A reads the `display` overlay `apexe scan` computes
(`metadata["display"]["a2a"]["alias"]`) to name each skill; see
[Display Names](#display-names) below.

### Current limitation: no per-caller identity over the wire

apcore's ACL engine evaluates whatever roles a caller's `Context::identity`
carries, but `apexe a2a` (and `apexe serve`) do not yet populate
`Identity.roles` from a JWT claim or request header — every call served
today runs as a single implicit anonymous caller. Role-gated ACL rules
(`conditions: {roles: [...]}`) are therefore not yet reachable over MCP/A2A;
`--acl` is most useful today for apexe's readonly-allow / destructive-deny
default policy (§9.1), alongside `--enable-approval` (§9.6), whose prompt
asks the connected client rather than keying on a caller identity.
See [`examples/acl_demo`](../examples/acl_demo/) for a library-level
demonstration of the same role-based ACL contract, driven directly against
the `Executor`.

---

## 12. Integrating with AI Agents

### `--show-config` reproduces the invocation you type

`--show-config` renders the *rest of the command line* into the snippet, so add
every flag you intend to serve with:

```bash
apexe serve --show-config claude-desktop \
  --modules-dir /srv/apexe/modules --prefix cli.git --acl /etc/apexe/acl.yaml
```

```json
{
  "mcpServers": {
    "apexe": {
      "command": "apexe",
      "args": ["serve", "--transport", "stdio",
               "--modules-dir", "/srv/apexe/modules",
               "--prefix", "cli.git",
               "--acl", "/etc/apexe/acl.yaml"]
    }
  }
}
```

`--modules-dir`, `--tags`, `--prefix`, `--acl`, `--name`, `--enable-approval`,
`--no-logging`, `--no-log-arguments`, `--no-circuit-breaker` and `--no-retry`
are all carried through. `--modules-dir` matters most: a client launching
`apexe serve` without it reads the default `~/.apexe/modules`, so anyone who
scanned elsewhere would get a server with **no tools at all**.

**Paths are made absolute.** A relative `--modules-dir ./modules` is written
into the snippet as `/abs/path/to/cwd/modules`, resolved against the directory
you ran `--show-config` in. The client that later runs the snippet launches
`apexe` from its own working directory, so a relative path would resolve
somewhere else entirely — the server would log `Modules directory not found,
starting with zero tools` and exit 0, and a relative `--acl` would silently
apply no policy at all.

> **Credentials are never written into a snippet.** `--auth-token` and
> `--jwt-secret` are excluded by construction, because a config file is shared
> and often committed. For an HTTP server behind `--auth token`, the snippet
> carries only the URL — configure the `Authorization: Bearer` header in the
> client, from your own secret store.

An unrecognised target (`--show-config vscode`) is rejected on **stderr** with
a non-zero exit, so `apexe serve --show-config … > mcp.json` cannot write an
error message into the config file.

### Claude Desktop

```bash
apexe scan git curl grep
apexe serve --show-config claude-desktop
```

Copy the JSON output into:
- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
- **Linux**: `~/.config/claude/claude_desktop_config.json`

Restart Claude Desktop. Scanned tools appear as MCP tools.

### Cursor

```bash
apexe serve --show-config cursor
```

Add the JSON to Cursor's MCP settings. `cursor` honours `--transport`: with
`--transport http` it emits the `url` form rather than a command block.

### HTTP Mode (Remote Agents)

```bash
apexe serve --transport http --host 0.0.0.0 --port 8000
apexe serve --show-config claude-desktop --transport http --port 8000
```

The MCP endpoint is at `POST /mcp` (the deprecated SSE transport is served at
`GET /sse` instead, and the generated snippet uses whichever path matches
`--transport`).

### Display Names

apexe generates display metadata for MCP clients:

| Module ID | MCP Display Alias |
|-----------|------------------|
| `cli.git.commit` | `git_commit` |
| `cli.docker.container.ls` | `docker_container_ls` |
| `cli.curl` | `curl` |

Aliases are auto-sanitized for MCP compatibility (dots replaced with underscores, digit prefixes escaped).

---

## 13. Error Handling & AI Guidance

Every error includes `ai_guidance` to help AI agents self-correct:

| Error | ai_guidance |
|-------|-------------|
| Tool not found | "The tool 'xyz' is not installed. Install it and try again." |
| Command timeout | "The command took too long. Try with simpler arguments or increase timeout." |
| Shell injection detected | "Remove shell metacharacters (;, \|) from parameter 'file'." |
| Permission denied | "Permission denied. Check file permissions or run with appropriate privileges." |
| Non-zero exit code | "Command 'git push' exited with code 1. stderr: (first 200 chars)" |

Additionally, each execution response includes:
- `trace_id` for end-to-end correlation
- `duration_ms` for performance tracking
- `exit_code` for programmatic error detection

### A non-zero exit is **not** an MCP error — key on `exit_code`

A wrapped command that runs and exits non-zero comes back as an ordinary,
successful tool result: **`isError: false`**, with `exit_code`, `stdout`,
`stderr` and `ai_guidance` in the payload.

`exit_code` is **not** a sibling of `isError`. MCP's `tools/call` result has a
fixed shape — `content` plus `isError` — and apcore-mcp puts the whole payload
into `content[0].text` as a **JSON string**:

```json
{
  "content": [
    {
      "type": "text",
      "text": "{\"exit_code\":2,\"stdout\":\"\",\"stderr\":\"curl: option --max-time=1: is unknown\",\"ai_guidance\":\"Command 'cli.curl' exited with code 2. stderr: ...\"}"
    }
  ],
  "isError": false
}
```

So a client reads `exit_code` by JSON-parsing `content[0].text` first:

```js
const payload = JSON.parse(result.content[0].text);
if (payload.exit_code !== 0) { /* the command ran and answered */ }
```

Reading `result.exit_code` directly yields `undefined`, and a client that then
falls back to `isError` lands exactly in the trap the rest of this section
exists to prevent.

This is deliberate and will not change. In a CLI bridge, a non-zero exit is
frequently the *answer*, not a failure:

| Command | Exit 1 means |
|---------|--------------|
| `grep pattern file` | no match found |
| `diff a b` | the files differ |
| `test -f path` | the predicate is false |

Setting `isError: true` on a non-zero exit would report all three as failed
tool calls, and an agent would retry or abandon a tool that answered its
question correctly.

`isError: true` is reserved for **the call never reaching the binary**:

- schema validation rejected the arguments,
- the ACL denied the caller,
- `--enable-approval` blocked a `requires_approval` module,
- the circuit breaker was open, the timeout killed the process, or the binary
  could not be spawned at all.

On that path `content[0].text` is a plain diagnostic sentence rather than a
JSON payload, so parsing it is only meaningful when `isError` is `false`.

**So: an MCP client must not treat `isError: false` as "the command
succeeded".** Parse `content[0].text` and read `exit_code` from it (it is
`required` in every generated output schema, alongside `stdout` and `stderr`),
then apply the wrapped tool's own convention.

The A2A surface follows the same split: a non-zero exit yields
`TASK_STATE_COMPLETED` with the `exit_code` inside the artifact, while a
governance or validation refusal yields `TASK_STATE_FAILED` (or
`TASK_STATE_INPUT_REQUIRED` when an approval is pending, and
`TASK_STATE_CANCELED` on cancellation). There too, the task state answers "did
the command run", not "did it succeed".

---

## 14. File Locations

| Path | Purpose | Created by |
|------|---------|------------|
| `~/.apexe/config.yaml` | Configuration | `apexe config --init` |
| `~/.apexe/modules/*.binding.yaml` | Tool binding files | `apexe scan` |
| `~/.apexe/cache/` | Scan result cache | `apexe scan` |
| `~/.apexe/acl.yaml` | Access control rules | `apexe scan` |
| `~/.apexe/audit.jsonl` | Audit trail | `apexe serve` (runtime) |
| `~/.apexe/apcore.yaml` | apcore ecosystem config (optional) | Manual |

All directories are created automatically on first use.

---

## 15. Global Flags, Logging & Debugging

Two flags are global — they parse before or after the subcommand, whichever is
more natural to type:

| Flag | Default | Description |
|------|---------|-------------|
| `--log-level <LEVEL>` | `RUST_LOG` → `APEXE_LOG_LEVEL` / `config.yaml` → `info` | Verbosity for the `tracing` stream |
| `--timeout <SECS>` | `default_timeout` from `config.yaml` (30) | Per-call timeout override, applied to every subcommand that runs or probes a wrapped binary. Refuses `0`, which would kill every call before it started |

```bash
apexe --timeout 120 scan ffmpeg     # a slow tool needs longer than the default
apexe scan ffmpeg --timeout 120     # identical; both positions parse
```

apexe uses structured logging via the `tracing` crate.

```bash
# Via CLI flag (global)
apexe --log-level debug scan git

# Via environment variable
RUST_LOG=debug apexe scan git

# Via config file
# log_level: debug
```

| Level | Shows |
|-------|-------|
| `error` | Failures only |
| `warn` | Warnings (e.g., failed to write ACL, cache miss) |
| `info` | Normal operation: tool loaded, modules registered, server started |
| `debug` | Internal detail: parser selection, cache hits, enrichment decisions |
| `trace` | Very verbose: raw help text, parsed structures |

---

## 16. Troubleshooting

### "Tool not found" during scan

The tool must be on `$PATH`:
```bash
which <tool>
```

### Scan produces incomplete results

1. Increase depth: `apexe scan <tool> --depth 3`
2. Force re-scan: `apexe scan <tool> --no-cache`
3. Check parser selection: `RUST_LOG=debug apexe scan <tool>`

### Serve command does nothing (stdio mode)

Stdio mode reads JSON-RPC from stdin and writes to stdout. It is launched by AI agents, not run interactively. Use `--show-config` to get the agent integration snippet.

### Tool invocation fails with ACL denied

The default ACL denies destructive and unknown commands. Edit `~/.apexe/acl.yaml`:

```yaml
rules:
  - callers: ["*"]
    targets: ["cli.<tool>.<command>"]
    effect: allow
```

### Stale scan results

```bash
apexe scan <tool> --no-cache
# Or clear cache entirely:
rm -rf ~/.apexe/cache/
```

### Known Limitations

- **A2A per-caller identity**: `apexe a2a` (and `apexe serve`) do not yet populate `Identity.roles` from a JWT/header, so role-gated ACL rules are unreachable over the wire — see [§11](#11-a2a-server).
- **Windows**: Not supported.
- **Interactive CLI tools**: Tools requiring stdin input (e.g., `ssh`, `vim`) cannot be wrapped.
- **Streaming output**: CLI subprocess output is collected in full, then returned. No real-time streaming.