zad-cli 0.9.4

Command-line interface for zad — connects AI agents to external services (Discord, Slack, Google Calendar, Spotify, Telegram, YouTube Music, 1Password) via scoped service configurations.
Documentation
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
# Services

A **service** in zad is an integration with an external system — a
chat platform, an issue tracker, a source-control host, anything a
long-lived bot identity can talk to over HTTP, WebSocket, or another
durable transport. Services are the unit zad ships, configures, and
permissions. An agent drives a service by its verbs (`zad <service>
<verb>`); a human administers it by its lifecycle commands (`zad
service <action> <service>`).

Today the shipped services are `1pass` (1Password), `discord`, `gcal`
(Google Calendar), `slack`, `spotify`, `telegram`, and `ymusic`
(YouTube Music). This document describes the shape every service
conforms to, so adding `github`, `matrix`, or another provider is
mechanical rather than speculative.

`1pass` is the single deliberate deviation from the pattern: its
read-side permissions act as **filters** rather than as
`PermissionDenied` sources. Anything out of scope is presented to the
agent as nonexistent — no information leak about which vaults, items,
or fields are being hidden. Write-side (`create`) still uses
`PermissionDenied` as every other service does. See
[`man/1pass.md`](../man/1pass.md) for the full story.

## What a service is, operationally

A service is four things at once:

1. **A bot identity** at a third-party provider (Discord application,
   GitHub App installation, Slack app, …) with a long-lived
   credential — typically a bot token or OAuth refresh token.
2. **A credentials file** on disk that records everything *except*
   the secret: the application ID, the declared scopes, any
   non-secret defaults (e.g. a default guild). The secret itself
   lives in the OS keychain, never in the TOML.
3. **A Rust module** under `crates/zad/src/service/<name>/` that
   implements the `Service` trait
   (`crates/zad/src/service/mod.rs`) and translates between zad's
   domain types and the provider's SDK.
4. **A CLI surface** — a pair of command groups (`zad service …` for
   lifecycle, `zad <name> …` for runtime verbs) and a manpage at
   `man/<name>.md`.

Every service also exposes a uniform enablement state: a project opts
into a service by listing it in
`~/.zad/projects/<slug>/config.toml`. Registering credentials and
enabling the service are deliberately separate steps so one set of
credentials can back many projects.

## Anatomy of a service module

```
crates/zad/src/service/<name>/
  mod.rs          — struct implementing `Service` (send_message / read_messages / listen / manage),
                    plus pub-use re-exports of the typed facade
  client.rs       — HTTP wrapper; translates domain types ↔ SDK types; enforces scopes locally
  facade.rs       — typed library facade: `<Service>` struct + `*Request`/`*Response` types,
                    three constructors, automatic permission enforcement
  transport.rs    — (optional) runtime-verb trait + live/dry-run impls for `--dry-run` preview
  gateway.rs      — (optional) event listener that produces a `BoxStream<Event>`
  permissions.rs  — per-service schema composed from the generic primitives under
                    `crates/zad/src/permissions/`; exposes a `load_from(global, local)` for env-free loading
```

The CLI side lives in a sibling crate:

```
crates/zad-cli/src/cli/
  <name>.rs           — clap dispatch, prompts, output formatting; calls into the facade
  service_<name>.rs   — `LifecycleService` impl driven by `cli/lifecycle.rs`
```

The `Service` trait is intentionally small:

```rust
#[async_trait]
pub trait Service: Send + Sync {
    fn name(&self) -> &'static str;
    async fn send_message(&self, target: Target, body: &str) -> Result<MessageId>;
    async fn read_messages(&self, channel: ChannelId, limit: usize) -> Result<Vec<Message>>;
    async fn listen(&self) -> Result<BoxStream<'static, Event>>;
    async fn manage(&self, cmd: ManageCmd) -> Result<()>;
}
```

Shared domain types — `ChannelId`, `MessageId`, `UserId`, `Target`,
`Message`, `Event`, `ManageCmd` — live in
`crates/zad/src/service/mod.rs` so services never leak their SDK's
types across the trait boundary. A
provider concept that has no zad equivalent (Discord threads, Slack
workspaces, GitHub repos) is exposed through service-specific methods
on the concrete client (e.g. `DiscordHttp::join_channel`), not the
trait.

## Three layers of access control

zad gates every service call through three independent layers. Each
is enforced *before* any network I/O.

| Layer | Question it answers | Where it lives |
|---|---|---|
| **Credentials** | Does zad even have a token for this service? | `~/.zad/services/<name>/config.toml` + OS keychain |
| **Scopes**      | Is this *family* of operations enabled? | `scopes = [...]` inside the credentials file |
| **Permissions** | Is *this specific call* (target, time, content) allowed? | Optional `permissions.toml` next to the credentials |

### Credentials

Credentials come in two scopes:

- **Global** at `~/.zad/services/<name>/config.toml` — shared across
  every project on the machine.
- **Local**  at `~/.zad/projects/<slug>/services/<name>/config.toml`
  — scoped to one project's working directory, where `<slug>` is
  the absolute path with `/`, `\`, and `:` replaced by `-`.

When both exist, the local file **replaces** the global one for that
project (credentials are *not* merged — write the full scope list each
time). Secrets are stored in the OS keychain at
`service="zad", account="<name>-<kind>:<scope>"` (for example
`discord-bot:global` or `discord-bot:-Users-alice-code-foo`).

### Scopes

The `scopes` array in the credentials file declares which families
of operations the service may perform. Every runtime verb names the
scope it requires; missing it fails with
`ZadError::ScopeDenied { service, scope, config_path }` — the error
message always names the exact file to edit.

Scopes are **coarse and declarative**. They don't depend on the
target, body, or time of day. They exist so a project can ship with
read-only Discord access while another project holds a separate set
of credentials that can also write.

### Permissions

Permissions narrow what a declared scope may actually touch. They
live in an optional TOML file next to the credentials:

- Global: `~/.zad/services/<name>/permissions.toml`
- Local:  `~/.zad/projects/<slug>/services/<name>/permissions.toml`

Unlike credentials, **both files apply simultaneously** — a call must
pass every file that exists, and a missing file contributes no
restrictions. This makes it safe to ship a strict global baseline: a
project can only add further restrictions, never loosen the rule.

Every service builds its schema on top of the same three primitives
in `crates/zad/src/permissions/`:

| Primitive | What it does | Typical keys |
|---|---|---|
| `pattern` | Allow/deny lists matched against a target alias. Supports exact names, `*`/`?` globs, and `re:<regex>`. | `channels.allow`, `channels.deny`, `users.allow`, `guilds.deny`, … |
| `content` | Deny-word and deny-regex screening for outbound bodies, plus an optional `max_length` cap measured in codepoints. | `deny_words`, `deny_patterns`, `max_length` |
| `time`    | UTC allow-window: which weekdays and which `HH:MM-HH:MM` slots admit calls (windows may cross midnight). | `days`, `windows` |

Each service declares **one block per runtime verb** (for Discord:
`[send]`, `[read]`, `[channels]`, `[join]`, `[leave]`, `[discover]`,
`[manage]`) and optional top-level `[content]` / `[time]` defaults
that every block inherits and can narrow further. Pattern lists run
against **every alias** of the target — the raw input (sigils
stripped), the resolved ID, and every directory entry that maps to
that ID — so a deny on `*admin*` fires even when the agent pastes
the raw snowflake.

Deny always beats allow; an empty allow list is "no positive
constraint", not "deny all". Violations surface as
`ZadError::PermissionDenied { function, reason, config_path }` —
same shape as the scope error, and again the message names the file
to edit.

## Dry-run preview (optional, per mutating verb)

Mutating runtime verbs may expose a `--dry-run` flag that short-circuits
the network call and prints what *would* have been sent. Dry-run is
intentionally **orthogonal** to the three access-control layers: scope
and permission checks still fire, so a preview respects the same policy
boundary as a live call. The keychain read is skipped, so `--dry-run`
works before a bot is even configured — a common agent workflow is
"preview the shape of my call, then register credentials and re-run
without the flag".

The interception layer is trait-based and reusable across services:

| Primitive | Lives in | Purpose |
|---|---|---|
| `DryRunOp`, `DryRunSink`, `StderrTracingSink` | `crates/zad/src/service/mod.rs` | Cross-service record + default sink (a summary via `tracing::info!` plus the JSON payload on stdout). Every service wrapper emits to the same sink type. |
| `<Name>Transport` | `crates/zad/src/service/<name>/transport.rs` | Service-specific trait over the runtime verbs the CLI layer calls. One method per verb, typed in zad's domain types (no SDK leakage). |
| Live impl for the HTTP client | same file | Blanket `impl <Name>Transport for <Name>Http` that delegates to the inherent methods — the live path is unchanged, the trait is a thin façade. |
| `DryRun<Name>Transport` | same file | Preview impl. Mutating verbs emit a `DryRunOp` and return a stub (`MessageId(0)` for sends, `Ok(())` for joins/leaves/channel creates). Read verbs return empty vectors — they're not dry-run-eligible by convention. |

The CLI factory that materialises a client (`discord_http_for` for
Discord) takes a `dry_run: bool`, runs the scope check unconditionally,
and then returns either `Box::new(<Name>Http::new(&token, …))` or
`Box::new(DryRun<Name>Transport::new(default_dry_run_sink()))` as a
`Box<dyn <Name>Transport>`. The per-verb handlers stay oblivious — they
call `transport.send(…)` and check `args.dry_run` only to suppress the
trailing `"Sent …"` line that would otherwise falsely claim success.

Convention: **`--dry-run` belongs only on mutating verbs** (writes
visible to the remote service). Reads have no side effect to preview,
and making them dry-run-capable forces the sink to invent data. Local
mutations (e.g. Discord's `discover` writing the directory cache) are
out of scope — dry-run is about external side effects, not local state.

## Standard file layout

```
~/.zad/
  services/<name>/
    config.toml             — global credentials for <name>
    permissions.toml        — global permissions policy for <name> (optional)
  projects/<slug>/
    config.toml             — records which services this project uses
    services/<name>/
      config.toml           — project-local credentials for <name> (optional)
      permissions.toml      — project-local permissions (optional)
      <service-specific>…   — e.g. discord's `directory.toml`
```

The project's own `config.toml` never contains credentials. It only
holds opt-in markers of the form:

```toml
[service.<name>]
enabled = true
```

A project is "using" a service iff that key is present.

## Standard CLI surface

Every service ships two command groups with identical shapes.

### Lifecycle — `zad service <action> <service>`

| Action | Meaning |
|---|---|
| `create <service>`  | Register credentials (global by default, `--local` for project-scoped). Prompts interactively or takes flags; validates the secret before storing it. |
| `enable <service>`  | Add `[service.<name>] enabled = true` to this project's config. Credentials must already exist in some scope. |
| `disable <service>` | Inverse of `enable`. Leaves credentials untouched. |
| `list`              | Table of every known service with credential scope + project enablement. |
| `show <service>`    | Effective configuration and both scopes' details (never prints the secret). |
| `delete <service>`  | Inverse of `create` — removes the config file at the chosen scope and clears the matching keychain entry. |

Every command accepts `--json` for machine-readable output. Every
command names the exact file path it read or wrote.

### Runtime — `zad <service> <verb>`

Runtime verbs are service-specific but follow a few conventions:

- The project must already be opted in (`zad service enable <service>`).
- Credentials are resolved with **local winning over global**.
- The required scope is checked locally before any network call.
- The required permission block is checked locally before any
  network call.
- Every verb supports `--json` and prints the exact file to edit on
  any denial.

Every service must also ship a `permissions` subgroup with four
verbs with identical names:

| Verb | Behaviour |
|---|---|
| `show` | Print both candidate file paths plus the body of whichever files exist. |
| `path` | Print the two candidate paths, one per line (script-friendly). |
| `init [--local] [--force]` | Write a starter policy with safe defaults. |
| `check --function <name> [--target <id\|name>] [--body <text>]` | Dry-run a proposed call without hitting the network; exits 0 on allow, 1 on deny, printing the reason and config path. |

The concrete flags on `check` depend on the service's target kinds —
for Discord, `--channel`, `--user`, and `--guild`.

## Name directories (optional per-service cache)

Any service that identifies resources by opaque IDs may ship a
project-local **directory** mapping ergonomic names to those IDs.
Discord stores one at
`~/.zad/projects/<slug>/services/discord/directory.toml`; a future
Slack service would store channel and user IDs the same way.

The directory is populated by a service-specific `discover` verb
(best-effort, re-runnable, merges on top of hand-authored entries),
inspected by `<service> directory`, and consumed implicitly whenever
a verb accepts `--channel`, `--user`, or `--guild` with a name
instead of a raw ID. Permission rules evaluate against every name
the directory knows for a resolved ID, so a deny pattern based on
names is robust even when the agent pastes a numeric ID.

## Adding a new service

Adding a service — Telegram, Slack, Reddit, GitHub App, Matrix, IRC,
whatever — goes through three points of contact: a registry entry, a
`LifecycleService` impl for the lifecycle commands, and (when the
service has runtime verbs) the usual `Service` trait + permissions +
manpage + example. The lifecycle surface is the same for every
service; the runtime surface is service-specific.

### 1. Pick a credential shape

Every service is one of these patterns. The shape drives which clap
helpers you flatten in and how many keychain entries you write.

| Shape | Example services | Helpers |
|---|---|---|
| One long-lived bot token | Discord, Telegram, Slack bot | `#[command(flatten)] BotTokenArgs` + one `secrets::account(NAME, "bot", scope)` entry |
| OAuth (client_secret + refresh_token) | Google Calendar (gcal), Reddit | Declare your own `--client-id` / `--client-secret` / `--refresh-token` flags; store **three** keychain entries with `kind = "client-id" / "client-secret" / "refresh"` |
| OAuth (PKCE public client — no secret) | Spotify | Same flags minus `--client-secret`; store **two** keychain entries with `kind = "client-id"` and `kind = "refresh"`. Use [`crate::oauth`] with `client_secret: None`. |
| OAuth (device flow, shared client) | YouTube Music (ymusic) | Single `--refresh-token` flag; the OAuth client_id / client_secret are TVHTML5 constants compiled into the binary. Use [`crate::service::ymusic::oauth_device::run_device_flow`]. Store **one** keychain entry with `kind = "refresh"`. |
| Keypair / PEM | GitHub App | A `--private-key-file` flag; store the PEM bytes under `kind = "pem"` (plus `app_id`/`installation_id` as non-secret `Cfg` fields) |
| User + password → access token | Matrix, IRC SASL | A `--username` flag + interactive password prompt; store just the derived access token under `kind = "access"` |

If your provider doesn't fit, pick the nearest shape and extend — the
trait doesn't care as long as `store_secrets` / `delete_secrets` /
`inspect_secrets` all agree on the list of accounts they touch.

The OAuth loopback flow lives at `crates/zad/src/oauth/` (top-level,
not under `crates/zad/src/service/`) and is shared by every
OAuth-based service.
Provider-specific knobs (endpoints, public-client vs. confidential
client, extra query params like Google's `prompt=consent` or
Spotify's `show_dialog=true`) are threaded in via `LoopbackConfig`.
Re-use it; do not re-implement the flow.

### 2. Checklist

Items 1–8 give you a fully working `zad service {create, enable,
disable, show, delete} <name>`. Items 9–14 add runtime verbs and the
typed library facade — both are required for a fully shipped service;
a lifecycle-only service is a valid interim state.

1. **Register the service.** Add `"<name>"` to
   `SERVICES` in `crates/zad/src/service/registry.rs`.
2. **Create `crates/zad/src/service/<name>/mod.rs`.** At minimum: a
   struct you plan to hang runtime methods on. May be stubbed — the
   lifecycle commands don't need a working client.
3. **Extend `ProjectConfig`** in `crates/zad/src/config/schema.rs`: add
   `<name>(&self) -> Option<&ServiceProjectRef>`,
   `enable_<name>(&mut self)`, `disable_<name>(&mut self)`.
4. **Add the per-service config struct** in
   `crates/zad/src/config/schema.rs` (e.g. `TelegramServiceCfg { bot_username,
   scopes, default_chat_id }`) — serde-derived, flat keys. Non-secret
   fields only.
5. **Create `crates/zad-cli/src/cli/service_<name>.rs`** — see the
   skeleton below.
6. **Add dispatch variants** to the five enums in
   `crates/zad-cli/src/cli/service.rs` (`CreateService`,
   `EnableService`, …) and one match arm in each of the five match
   blocks, routing to `lifecycle::run_*::<<Name>Lifecycle>(a)`.
7. **Add `crates/zad-cli/tests/cli_service_<name>_test.rs`** mirroring
   `crates/zad-cli/tests/cli_service_discord_test.rs`.
8. **Run `make fmt lint build test`**. Lifecycle is now wired
   end-to-end. (`oss-spec validate .` is an on-demand conformance
   check — useful when introducing structural changes, not required
   for every PR.)

9. **Implement the `Service` trait** in
   `crates/zad/src/service/<name>/mod.rs` when you're ready to ship
   runtime verbs. Keep the provider's SDK a private dependency of the
   client — never leak its types across the trait boundary. (Skip the
   trait if your service doesn't fit chat-style send/read/listen — see
   gcal/spotify/ymusic.)
10. **Enforce scopes locally** in the client: each method asserts
    the scope it needs *before* the network call and returns
    `ZadError::ScopeDenied { service: NAME, scope, config_path }`
    otherwise.
11. **Compose a permissions schema** in
    `crates/zad/src/service/<name>/permissions.rs` from
    `PatternListRaw`, `ContentRulesRaw`, `TimeWindowRaw`, with
    one per-function block. Expose
    `EffectivePermissions { global, local }` with one
    `check_<verb>_<target>` method per runtime verb. Add a
    `pub fn load_from(global: Option<&Path>, local: Option<&Path>)
    -> Result<EffectivePermissions>` next to `load_effective` so
    library callers can pin permission paths without env-var
    influence.
12. **Build the typed library facade** in
    `crates/zad/src/service/<name>/facade.rs` (see "Typed library
    facade" below for the canonical recipe). Re-export the types from
    `mod.rs`. **Required** — the typed facade is what makes the
    service callable from a Rust crate that depends on `zad`.
13. **Wire runtime verbs** under `crates/zad-cli/src/cli/<name>.rs`
    (the group entrypoint used by `zad <name> <verb>`) and add
    `Command::<Name>(...)` to `crates/zad-cli/src/cli/mod.rs`. CLI
    handlers should call into the facade rather than re-doing
    permission-load + check + transport-call. Include the mandatory
    `permissions` subgroup with `show` / `path` / `init` / `check`.
14. **Optional: `--dry-run`** for mutating verbs via the
    `<Name>Transport` pattern described in the *Dry-run preview*
    section above. Reuse `default_dry_run_sink()` from
    `crates/zad/src/service/mod.rs`; don't reinvent the sink.
15. **Write `man/<name>.md`, ship `examples/<name>-permissions/`,
    and update `docs/configuration.md`** with the credentials
    schema and any service-specific files. Optionally add
    `examples/<name>-library/` (a runnable Rust crate
    demonstrating the facade) — required when the facade lands so
    the typed-input contract is documented for downstream users.

### Typed library facade — the canonical recipe

Every service must expose a typed Rust API so a downstream crate that
writes `zad = "0.6"` in its `Cargo.toml` can call zad's functionality
without shelling out to the binary. The facade in
`crates/zad/src/service/<name>/facade.rs` is the contract.

**Required ingredients:**

1. **A `<Service>` struct** that owns the client (`<Service>Http`)
   plus optional `EffectivePermissions` plus an optional `Directory`
   for permission-rule alias lookup. Construction is the only place
   `Self` is built; downstream callers never poke at the fields
   directly.
2. **Three constructors**, in this order:
   - `<Service>::from_default_config()` — CLI-equivalent. Loads
     project-or-global config from `~/.zad/...`, the credential(s)
     from the OS keychain, and `permissions.toml` from the default
     paths. **Honors `ZAD_HOME_OVERRIDE`, `ZAD_PERMISSIONS_PATH`,
     `ZAD_PERMISSIONS_ROOT`, and `ZAD_SECRETS_MEMORY`** — document
     this loudly in the rustdoc.
   - `<Service>::with_token(token, scopes, config_path)` (or
     `with_credentials(creds, ...)` for OAuth services) — explicit
     credentials. **Reads no env vars.** No on-disk permission
     enforcement; layer back on with `with_permissions(...)`.
   - `<Service>::with_paths(token_or_creds, scopes, config_path,
     global_permissions: Option<&Path>, local_permissions:
     Option<&Path>)` — fully explicit. **Reads no env vars.** This
     is the recommended entry point for production library code,
     multi-tenant servers, and deterministic tests.
3. **Validating `*Request` types per verb.** Construction goes
   through `Request::new(...)` (not literal struct init), and the
   constructor enforces every constraint that the underlying API
   would reject for shape reasons: max body length, max attachment
   count, valid range for `limit`, non-empty required strings.
   Surface failures as `ZadError::Invalid(...)`. Once a `Request`
   exists, the only failures left at the call site are network /
   permission / scope.
4. **Newtypes for every ID** — `ChannelId`, `MessageId`, `UserId`,
   `ChatId` etc. — never raw `String` or `u64` in public verb
   signatures. The point is that a function taking a `ChannelId`
   cannot be called with a user ID by accident.
5. **Closed enums for any "one of these" choice** —
   `Target::Channel | Target::Dm`, `MessageBody::Text | Empty`,
   etc. Public APIs never take stringly-typed kind discriminators.
6. **Permission and scope enforcement inside facade methods**, in
   exactly this order: `check_time(...)`, target check
   (`check_send_channel`, `check_read_chat`, …), body check, then
   the transport call. Errors come back as
   `ZadError::PermissionDenied { function, reason, config_path }`
   — same variants the CLI emits.
7. **A typed-input test** at
   `crates/zad/tests/<name>_facade_test.rs` (mirroring
   `discord_facade_test.rs`). Exercise every validating constructor's
   reject paths plus at least one happy path. No network, no
   keychain, no filesystem — these tests are evidence that the
   library refuses malformed calls at the type/value level.

**Reference implementations:** Discord
(`crates/zad/src/service/discord/facade.rs`) for the bot-token
shape, Gcal (`crates/zad/src/service/gcal/facade.rs`) for full OAuth
(`client_id` + `client_secret` + `refresh_token`), Spotify for OAuth
PKCE (`client_id` + `refresh_token`, no secret), OnePass for the
`op`-binary shell-out shape.

### 3. Paste-ready `LifecycleService` skeleton

Drop this into `crates/zad-cli/src/cli/service_<name>.rs` and edit
every line marked `EDIT:`. Discord uses exactly this shape (see
`crates/zad-cli/src/cli/service_discord.rs` for the canonical
reference).

```rust
use async_trait::async_trait;
use clap::Args;

use crate::cli::lifecycle::{
    BotTokenArgs, CreateArgsBase, CreateArgsLike, LifecycleService, ScopesArg,
    SecretRef, resolve_bot_token, resolve_scopes,
};
use zad::config::{ProjectConfig, TelegramServiceCfg};   // EDIT: your Cfg
use zad::error::{Result, ZadError};
use zad::secrets::{self, Scope};

const DEFAULT_SCOPES: &[&str] = &["messages.read", "messages.send"];    // EDIT
const ALL_SCOPES: &[&str] = &["messages.read", "messages.send", "chats.manage"]; // EDIT

/// EDIT: secret material your service needs in the keychain.
/// One token for a bot; `{ client_secret, refresh_token }` for OAuth;
/// `{ pem: Vec<u8> }` for a GitHub App; etc.
pub struct TelegramSecrets {
    pub bot_token: String,
}

#[derive(Debug, Args)]
pub struct CreateArgs {
    #[command(flatten)] pub base: CreateArgsBase,
    #[command(flatten)] pub token: BotTokenArgs,      // EDIT: drop if not a bot-token service
    #[command(flatten)] pub scopes: ScopesArg,        // EDIT: drop if scopes don't apply
    /// EDIT: your service-specific non-secret fields.
    #[arg(long)] pub bot_username: Option<String>,
    #[arg(long)] pub default_chat_id: Option<String>,
}

impl CreateArgsLike for CreateArgs {
    fn base(&self) -> &CreateArgsBase { &self.base }
}

pub struct TelegramLifecycle;

#[async_trait]
impl LifecycleService for TelegramLifecycle {
    const NAME: &'static str = "telegram";                   // EDIT
    const DISPLAY: &'static str = "Telegram";                // EDIT
    type Cfg = TelegramServiceCfg;                           // EDIT
    type Secrets = TelegramSecrets;                          // EDIT
    type CreateArgs = CreateArgs;

    fn enable_in_project(cfg: &mut ProjectConfig) { cfg.enable_telegram(); }  // EDIT
    fn disable_in_project(cfg: &mut ProjectConfig) { cfg.disable_telegram(); } // EDIT

    async fn resolve(args: &CreateArgs, non_interactive: bool)
        -> Result<(TelegramServiceCfg, TelegramSecrets)>
    {
        // EDIT: prompt-or-fail for each Option<_> field in your args.
        // `resolve` is async so you can call provider APIs from here
        // (e.g. validate a user-supplied snowflake, poll for a
        // self-identity message) before returning the final Cfg.
        let bot_username = args.bot_username.clone()
            .ok_or(ZadError::MissingRequired("--bot-username"))?;
        let default_chat_id = args.default_chat_id.clone();
        let scopes = resolve_scopes(
            args.scopes.scopes.as_deref(), DEFAULT_SCOPES, ALL_SCOPES, non_interactive)?;
        let bot_token = resolve_bot_token(
            args.token.bot_token.as_deref(),
            args.token.bot_token_env.as_deref(),
            non_interactive, Self::DISPLAY)?;
        Ok((
            TelegramServiceCfg { bot_username, scopes, default_chat_id },
            TelegramSecrets { bot_token },
        ))
    }

    async fn validate(_cfg: &TelegramServiceCfg, s: &TelegramSecrets) -> Result<String> {
        // EDIT: call the provider's whoami/auth-test endpoint. On error:
        //   Err(ZadError::Service { name: Self::NAME, message: format!(...) })
        Ok(format!("<unvalidated: {} chars>", s.bot_token.len()))
    }

    fn store_secrets(s: &TelegramSecrets, scope: Scope<'_>) -> Result<Vec<SecretRef>> {
        let account = secrets::account(Self::NAME, "bot", scope);
        secrets::store(&account, &s.bot_token)?;
        Ok(vec![SecretRef { label: "token", account, present: true }])
        // For multi-secret services, write each piece and return one
        // SecretRef per keychain entry — the driver renders each as a
        // separate line in `show` / `create` output.
    }

    fn delete_secrets(scope: Scope<'_>) -> Result<Vec<SecretRef>> {
        let account = secrets::account(Self::NAME, "bot", scope);
        secrets::delete(&account)?;
        Ok(vec![SecretRef { label: "token", account, present: false }])
    }

    fn inspect_secrets(scope: Scope<'_>) -> Result<Vec<SecretRef>> {
        let account = secrets::account(Self::NAME, "bot", scope);
        let present = secrets::load(&account)?.is_some();
        Ok(vec![SecretRef { label: "token", account, present }])
    }

    // Powers `zad service status --service <svc>` and the aggregate
    // `zad service status` — the driver reads secrets back out of the
    // keychain and hands them to
    // `validate` as a live "does this credential work?" check.
    // Return `Ok(None)` if any required account is missing at the
    // given scope; the driver will report "credentials_present: false"
    // without surfacing an error.
    fn load_secrets(scope: Scope<'_>) -> Result<Option<TelegramSecrets>> {
        let account = secrets::account(Self::NAME, "bot", scope);
        Ok(secrets::load(&account)?.map(|bot_token| TelegramSecrets { bot_token }))
    }

    fn cfg_human(cfg: &TelegramServiceCfg) -> Vec<(&'static str, String)> {
        let mut out = vec![("bot", cfg.bot_username.clone())];               // EDIT
        if let Some(c) = &cfg.default_chat_id { out.push(("chat", c.clone())); }
        out
    }

    fn cfg_json(cfg: &TelegramServiceCfg) -> serde_json::Value {
        serde_json::json!({                                                  // EDIT
            "bot_username": cfg.bot_username,
            "default_chat_id": cfg.default_chat_id,
        })
    }

    fn scopes_of(cfg: &TelegramServiceCfg) -> &[String] { &cfg.scopes }

    // Optional: surface a URL the user should visit after `create`
    // succeeds (e.g. an install/authorize page). Default impl returns
    // None. When set, the URL is printed under the create banner and
    // also opened in the system browser unless `--no-browser` was
    // passed.
    // fn post_create_hint(cfg: &TelegramServiceCfg) -> Option<String> {
    //     Some(format!("https://t.me/{}", cfg.bot_username))
    // }
}
```

### 4. Optional: a `self` identity and `@me`

Services where "the user" is a meaningful address (Discord, Telegram,
Slack, …) can expose an optional `self_*_id` field on their `Cfg` so
`zad <svc> send --<target> @me` resolves to the caller's own
account. Discord's `self_user_id` and Telegram's `self_chat_id` are
the reference implementations:

- Add the field to the `Cfg` as `Option<…>` with `#[serde(default,
  skip_serializing_if = "Option::is_none")]`. It's non-secret config,
  not a keychain entry.
- Take an optional `--self-<thing>` flag on `CreateArgs`. In
  non-interactive mode use the flag verbatim; in interactive mode
  prompt (or run a provider-specific capture flow) after the token is
  resolved — `resolve` is async, so provider calls are fair game.
- In the runtime CLI, special-case the literal `@me` (case-insensitive)
  in the target resolver **before** the directory lookup. Emit a clear
  error when the self-ID isn't set, pointing at the `self` subcommand.
- Wire a `self {show,set,clear,…}` subcommand group that mirrors
  `permissions` — same four-verb shape — so the field is manageable
  after create.
- Permission patterns match the raw input alongside the resolved ID,
  so `deny = ["@me"]` works automatically. Document this in the
  service's `examples/*-permissions/README.md`.

Services where "self" has no useful meaning (Reddit bots, GitHub Apps,
etc.) simply skip this — the pattern is opt-in.

### 5. Golden rules

- **Secrets never go in the TOML.** `Cfg` fields are for non-secret
  material only; everything sensitive flows through `Secrets` and
  ends up in the OS keychain via `secrets::account(NAME, kind, scope)`.
- **Use the generic error.** Provider failures go through
  `ZadError::Service { name: NAME, message }`. Add bespoke variants
  only for *structured* failures whose callers need to match on them
  (e.g. `DiscordChannelNotFound`).
- **Keychain account strings are stable.** Once shipped, the
  `(NAME, kind)` pair in `secrets::account` is a user-visible
  identifier — renaming it orphans every existing stored token.
- **`Cfg` must round-trip through serde.** `config::save_flat` /
  `load_flat` write and read the flat form; no fields may rely on
  runtime state.
- **Don't touch other services.** The trait's associated types keep
  services independent — scopes, verbs, and flags for one service
  must not change when another is added.

The `Service` trait, the `LifecycleService` trait, the permission
primitives, and the credentials/scopes/permissions three-layer model
are the contract. Everything else — thread-like sub-resources,
gateway sessions, service-specific discovery — is fair game to
expose as service-specific methods and verbs.

## See also

- [`docs/configuration.md`]configuration.md — credentials,
  permissions, and directory schemas in full.
- [`docs/architecture.md`]architecture.md — module layout and
  dependency direction for the crate as a whole.
- [`man/service.md`]../man/service.md — lifecycle command reference.
- [`man/discord.md`]../man/discord.md — the reference
  implementation of a service's runtime verbs.