@kurtbruns/kestrel 1.13.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +2 -2
  3. package/bin/kestrel.mjs +21 -2
  4. package/cli/dev.mjs +6 -2
  5. package/cli/init.mjs +2 -1
  6. package/cli/reset.mjs +1 -1
  7. package/cli/seed.mjs +3 -3
  8. package/dist/public/dashboard/app.js +290 -285
  9. package/dist/public/dashboard/app.js.map +4 -4
  10. package/dist/public/dashboard/index.html +2 -2
  11. package/dist/public/dashboard/styles.css +74 -40
  12. package/dist/worker/index.js +2723 -1192
  13. package/docs/README.md +3 -2
  14. package/docs/get-started/02-deploy.md +18 -21
  15. package/docs/get-started/03-access.md +10 -8
  16. package/docs/get-started/06-go-live.md +2 -1
  17. package/docs/guides/01-allow-claude.md +68 -0
  18. package/docs/guides/02-automate.md +88 -0
  19. package/docs/guides/09-as-your-list-grows.md +2 -2
  20. package/docs/publishing/01-your-list.md +3 -3
  21. package/docs/publishing/03-name-your-publication.md +1 -1
  22. package/docs/publishing/04-write-a-post.md +15 -52
  23. package/docs/publishing/06-troubleshooting.md +20 -9
  24. package/docs/publishing/08-connect-claude.md +60 -0
  25. package/docs/publishing/09-share-your-subscribe-link.md +1 -1
  26. package/docs/publishing/10-check-your-setup.md +9 -16
  27. package/docs/publishing/11-email-template.md +1 -1
  28. package/docs/publishing/README.md +2 -2
  29. package/docs/reference/01-configuration.md +132 -85
  30. package/docs/reference/02-sending-domain-dns.md +2 -2
  31. package/migrations/0013_who_acted_client.sql +20 -0
  32. package/migrations/0014_send_acts.sql +25 -0
  33. package/package.json +2 -1
  34. package/packages/kestrel/lib/api.mjs +462 -0
  35. package/packages/kestrel/lib/credentials.mjs +192 -0
  36. package/{cli → packages/kestrel/lib}/dev-api.mjs +21 -11
  37. package/{cli → packages/kestrel/lib}/dev-server.mjs +2 -2
  38. package/packages/kestrel/lib/handoff.mjs +118 -0
  39. package/packages/kestrel/lib/login.mjs +155 -0
  40. package/packages/kestrel/lib/oauth.mjs +368 -0
  41. package/packages/kestrel/lib/openapi.mjs +569 -0
  42. package/template/README.md +2 -0
  43. package/template/claude/CLAUDE.md +8 -5
  44. package/docs/guides/01-connect-claude.md +0 -101
  45. package/docs/publishing/07-working-with-claude.md +0 -29
package/docs/README.md CHANGED
@@ -4,7 +4,7 @@ These pages cover deploying and running your own Kestrel, from an empty reposito
4
4
 
5
5
  ## Get started
6
6
 
7
- Follow these steps, from an empty repository to a live newsletter. Each one ends with a check, so you know it worked before you move on.
7
+ These are the steps to deploy a Kestrel instance and get it ready to send. Each one ends with a check, so you know it worked before you move on.
8
8
 
9
9
  1. [Deploy the app](get-started/02-deploy.md)
10
10
  1. [Lock the dashboard with Access](get-started/03-access.md)
@@ -16,7 +16,8 @@ Follow these steps, from an empty repository to a live newsletter. Each one ends
16
16
 
17
17
  Each one stands on its own. Pick the ones you need.
18
18
 
19
- - [Connect Claude to the API](guides/01-connect-claude.md)
19
+ - [Allow Claude to connect](guides/01-allow-claude.md)
20
+ - [Automate with a service token](guides/02-automate.md)
20
21
  - [Notifications through Cloudflare's email](guides/03-notifications.md)
21
22
  - [Rate-limit the subscribe form](guides/05-rate-limit.md)
22
23
  - [As your list grows](guides/09-as-your-list-grows.md)
@@ -37,7 +37,7 @@ Your instance's repository holds its configuration, its database migrations, and
37
37
  ```bash
38
38
  git clone https://github.com/YOUR_USERNAME/newsletter.git
39
39
  cd newsletter
40
- npx @kurtbruns/kestrel init
40
+ npx kestrel init
41
41
  ```
42
42
 
43
43
  This writes the instance's files: `wrangler.jsonc` for its settings, `package.json` with the release it runs, the release's database migrations, and a starting point for Claude Code in `.claude/`. The instance's `README.md` lists its commands.
@@ -63,7 +63,7 @@ Your instance's repository holds its configuration, its database migrations, and
63
63
  npm run dev
64
64
  ```
65
65
 
66
- Open http://localhost:8787/dashboard/, and run `npm run seed` in a second terminal to load a demo publication. Email goes to a stand-in, so nothing reaches a real inbox. Stop the server with <kbd>Ctrl</kbd>+<kbd>C</kbd> when you're done.
66
+ Open http://localhost:8787/dashboard/, and run `npm run seed` in a second terminal to load a demo publication. Email goes to a stand-in, so nothing reaches a real inbox. Press Ctrl+C to stop the server when you're done.
67
67
 
68
68
  ## 2. Sign in to Cloudflare
69
69
 
@@ -85,7 +85,7 @@ Every command from here on acts on your Cloudflare account through the Wrangler
85
85
 
86
86
  ## 3. Create the database and the image bucket
87
87
 
88
- Each command creates the resource and writes its id or name into the `production` block of `wrangler.jsonc`, under the binding the app already uses, so there's nothing to copy.
88
+ Each command creates the resource and writes it into the `production` block of `wrangler.jsonc`, under the binding the app already uses. Name `--env production` on each one, or Wrangler writes to the top level, which is local development.
89
89
 
90
90
  1. Create the database:
91
91
 
@@ -93,29 +93,28 @@ Each command creates the resource and writes its id or name into the `production
93
93
  npx wrangler d1 create kestrel-production --env production --binding DB --update-config
94
94
  ```
95
95
 
96
- It prints the new database's settings, then a snippet it says to add to your configuration. Wrangler has already added it: it fills in the `database_id` of the production `DB` binding with the database's real id, in place of `REPLACE_WITH_PRODUCTION_D1_ID`, so there's nothing to paste.
97
-
98
96
  1. Create the bucket for images:
99
97
 
100
98
  ```bash
101
99
  npx wrangler r2 bucket create kestrel-media-production --env production --binding MEDIA --update-config
102
100
  ```
103
101
 
104
- It prints a snippet to add to your configuration too. There's nothing to paste here either: the production `MEDIA` binding already names this bucket, so it stays as it is.
105
-
106
- Wrangler rewrites the whole of `wrangler.jsonc` in its own layout when it saves: tabs, and one value per line. Your comments and settings stay; only the spacing changes.
107
-
108
- Name `--env production` on every Wrangler command that creates or binds a resource. Without it, Wrangler writes to the top level, which is local development.
102
+ Each prints a snippet to add to your configuration. There's nothing to paste: `--update-config` has filled in the database's id, and the `MEDIA` binding already names the bucket. Wrangler also rewrites `wrangler.jsonc` in its own layout, with tabs and one value per line, and your settings stay.
109
103
 
110
104
  ## 4. Configure production
111
105
 
112
106
  `wrangler.jsonc` holds two sets of settings. The top level is for local development, so leave it alone. The `production` block under `env` is yours to fill in.
113
107
 
114
- 1. Open `wrangler.jsonc` and change the values marked here in the `production` block. It's shown compactly here; in your file, after section 3, each value is on its own line:
108
+ 1. Open `wrangler.jsonc` and change the values marked here in the `production` block. Only the settings you change are shown; leave the others as they are:
115
109
 
116
110
  ```jsonc
117
111
  "production": {
118
- "routes": [{ "pattern": "newsletter.example.com", "custom_domain": true }], // ← your app's hostname
112
+ "routes": [
113
+ {
114
+ "pattern": "newsletter.example.com", // ← your app's hostname
115
+ "custom_domain": true
116
+ }
117
+ ],
119
118
  "d1_databases": [
120
119
  {
121
120
  "binding": "DB",
@@ -136,24 +135,22 @@ Name `--env production` on every Wrangler command that creates or binds a resour
136
135
 
137
136
  What each one does:
138
137
 
139
- - **`routes`** puts the app on your hostname when you deploy. Cloudflare creates the DNS record and the certificate. Your domain must be in the same Cloudflare account, and the hostname can't already have a CNAME record; delete one if it does. The block also turns off the `workers.dev` addresses, so the app answers only on your hostname.
140
- - **`PROVIDER` stays empty.** [Connect Amazon SES](04-ses.md) sets it. Until then the app runs without an email provider: posts can be written and previewed, but scheduling, test emails, and signups are refused. Never set it to `fake`, the top level's value, even when a wrangler warning suggests copying it: `fake` delivers nothing, and the app refuses it on your hostname.
141
- - **`ARCHIVE_BASE_PATH`** is permanent once the first post goes out. Every post sent carries its `/archive/…` link, and changing the prefix later breaks the links already mailed. Keep `/archive` unless you have a reason not to.
142
- - **`FROM_ADDRESS`** is the address every email comes from. Keep it on your sending hostname, and set `SENDING_DOMAIN` to the part after the `@`.
138
+ - `routes` puts the app on your hostname when you deploy. Cloudflare creates the DNS record and the certificate. Your domain must be in the same Cloudflare account, and the hostname can't already have a CNAME record; delete one if it does. The block also turns off the `workers.dev` addresses, so the app answers only on your hostname.
139
+ - `PROVIDER` stays empty until [Connect Amazon SES](04-ses.md) sets it. Until then the app runs without an email provider: posts can be written and previewed, but scheduling, test emails, and signups are refused. Never set it to `fake`, the top level's value, even when a wrangler warning suggests copying it: `fake` delivers nothing, and the app refuses it on your hostname.
140
+ - `ARCHIVE_BASE_PATH` is permanent once the first post goes out. Every post sent carries its `/archive/…` link, and changing the prefix later breaks the links already mailed. Keep `/archive` unless you have a reason not to.
141
+ - `FROM_ADDRESS` is the address every email comes from. Keep it on your sending hostname, and set `SENDING_DOMAIN` to the part after the `@`.
143
142
 
144
- Change every `example.com` value, the route included, even before you connect a provider. The app checks its settings but can't see the route: it refuses to run while `APP_ORIGIN`, `SENDING_DOMAIN`, or `FROM_ADDRESS`, or `ARCHIVE_ORIGIN` or `MEDIA_PUBLIC_BASE` if you set them, is still on `example.com`, since its links and the address your email comes from would name a domain that isn't yours.
143
+ Change every `example.com` value, the route included. The app refuses to run while any of its settings still names `example.com`.
145
144
 
146
145
  Every other setting has a working default. [Configuration](../reference/01-configuration.md) lists them all, and what the app refuses.
147
146
 
148
- 1. Check the file. This regenerates the binding types, and fails if the configuration doesn't parse:
147
+ 1. Check the file. It fails if the configuration doesn't parse:
149
148
 
150
149
  ```bash
151
150
  npm run typecheck
152
151
  ```
153
152
 
154
- Wrangler prints the binding types it generated and ends with a reminder to run it again whenever you change `wrangler.jsonc`. The type check that follows prints nothing when it passes.
155
-
156
- 1. Commit your settings and push them to your repository. An upgrade changes only the release your instance runs, so your settings stay as they are:
153
+ 1. Commit your settings and push them to your repository:
157
154
 
158
155
  ```bash
159
156
  git commit -am "Configure production"
@@ -33,7 +33,7 @@ The editor lives under `/dashboard`, and the API under `/api`. One application c
33
33
  | Path | What it covers |
34
34
  | --- | --- |
35
35
  | `dashboard` | the editor |
36
- | `api` | everything the editor and Claude read and change: posts, sends, subscribers, settings, and the in-app docs |
36
+ | `api` | everything the editor and Claude read and change: posts, sends, subscribers, settings, the in-app docs, and the MCP server Claude Desktop and claude.ai connect to (`/api/mcp`) |
37
37
 
38
38
  Each path covers everything under it. Leave every other path out. The home page, the archive, subscribing, unsubscribing, images, and your email provider's webhooks must stay public.
39
39
 
@@ -49,19 +49,21 @@ Every Access application denies everyone until a policy allows them.
49
49
  - **Action:** Allow
50
50
  - **Include:** the **Emails** selector, with your email address
51
51
 
52
- 1. Keep the login method a new Zero Trust organization starts with, Cloudflare's own: you enter your email, and confirm the sign-in from the message Cloudflare sends to it. By default it admits only members of your Cloudflare account, so the email in your `Publishers` policy should be one. To let someone sign in another way, such as a one-time code by email, add it under **Zero Trust → Integrations → Identity providers** first.
53
-
54
52
  1. Select **Create**.
55
53
 
54
+ Cloudflare's own login is on by default, and admits members of your Cloudflare account, so the email in your `Publishers` policy should be one. To let someone sign in another way, such as a one-time code by email, add it under **Zero Trust → Integrations → Identity providers**.
55
+
56
+ The app accepts only two kinds of policy on this application. One is an Allow policy on people's emails, like this one. The other is a Service Auth policy on a service token, from [Automate with a service token](../guides/02-automate.md). A login through any other kind, such as a certificate, a device check, or an identity provider that gives no email, is refused with a `401`, and the editor says so.
57
+
56
58
  ## 4. Give the app its two values
57
59
 
58
60
  The app verifies each login against your team domain, and checks that it was issued for this application.
59
61
 
60
- 1. Find your team domain under **Zero Trust → Settings**. It looks like `YOUR_TEAM.cloudflareaccess.com`. It belongs to your Zero Trust organization, not to this application, so every application in your account shares it. It's also where a signed-out visit to `https://newsletter.example.com/dashboard/` now takes you.
62
+ 1. Find your team domain under **Zero Trust → Settings**. It looks like `YOUR_TEAM.cloudflareaccess.com`.
61
63
 
62
64
  1. Find the application's audience tag. Under **Access controls → Applications**, select **Configure** on your application. On the **Additional settings** tab, copy the **Application Audience (AUD) Tag**.
63
65
 
64
- 1. Store the team domain as a secret. The command prompts you for the value. Enter the hostname alone, without `https://`. If you paste it with `https://` or a trailing slash, the app drops them. Anything else that isn't a hostname is refused, naming `ACCESS_TEAM_DOMAIN`:
66
+ 1. Store the team domain as a secret. The command prompts you for the value. Enter the hostname alone, without `https://`:
65
67
 
66
68
  ```bash
67
69
  npx wrangler secret put ACCESS_TEAM_DOMAIN --env production
@@ -73,7 +75,7 @@ The app verifies each login against your team domain, and checks that it was iss
73
75
  npx wrangler secret put ACCESS_AUD --env production
74
76
  ```
75
77
 
76
- A secret takes effect as soon as you store it, with no redeploy. The app needs both values. While only one is stored, it refuses its configuration and names the missing one: the editor says so, `/health` answers `500` with `"variable":"ACCESS_AUD"`, and the public pages say the newsletter is temporarily unavailable. Storing the second clears it.
78
+ A secret takes effect as soon as you store it, with no redeploy.
77
79
 
78
80
  1. **(Optional, recommended)** Limit the editor to named people. The Access policy decides who can sign in. This list keeps that true if the policy is widened later, for another app or by mistake. Enter a comma-separated list of email addresses at the prompt:
79
81
 
@@ -92,7 +94,7 @@ The app verifies each login against your team domain, and checks that it was iss
92
94
  1. In the same window, open `https://newsletter.example.com/api/whoami`. It shows you as a signed-in person, verified through Access:
93
95
 
94
96
  ```
95
- {"principal":{"kind":"human","email":"you@example.com"},"auth":{"mode":"access"}}
97
+ {"principal":{"kind":"human","email":"you@example.com","client":null},"auth":{"mode":"access"}}
96
98
  ```
97
99
 
98
100
  1. The public pages still need no login:
@@ -105,6 +107,6 @@ The app verifies each login against your team domain, and checks that it was iss
105
107
  {"status":"ok","service":"kestrel"}
106
108
  ```
107
109
 
108
- If the editor says the configuration needs a fix, store the secret it names. If Cloudflare lets you in but the editor answers `401`, check the two secrets for a typo, or `ACCESS_ALLOWED_EMAILS` may leave out your address.
110
+ If the editor says the configuration needs a fix, store the secret it names. The app needs both values, so while only one is stored, `/health` answers `500` naming the other, and the public pages say the newsletter is temporarily unavailable. If Cloudflare lets you in but the editor answers `401`, check the two secrets for a typo, or `ACCESS_ALLOWED_EMAILS` may leave out your address. If the editor says Access let you in but didn't say who you are, your login carried no email: sign out, and sign in through the `Publishers` policy.
109
111
 
110
112
  Next, [connect Amazon SES](04-ses.md).
@@ -18,7 +18,8 @@ If someone else is the publisher, let them sign in first:
18
18
 
19
19
  Each of these guides adds one thing, and you can come back to any of them later.
20
20
 
21
- - [Connect Claude to the API](../guides/01-connect-claude.md), so Claude can draft, proofread, and schedule posts through the same API the editor uses.
21
+ - [Allow Claude to connect](../guides/01-allow-claude.md), so the publisher can connect Claude Desktop, claude.ai, or Claude Code, and have it draft, proofread, and schedule posts as them, through the same API the editor uses.
22
+ - [Automate with a service token](../guides/02-automate.md), so a script can call the API with a service token, when no one is signed in.
22
23
  - [Notifications through Cloudflare's email](../guides/03-notifications.md), so problems are reported even when the email provider has stopped sending.
23
24
  - [Rate-limit the subscribe form](../guides/05-rate-limit.md), before the subscribe link is shared widely.
24
25
  - [As your list grows](../guides/09-as-your-list-grows.md), from about 1,000 subscribers.
@@ -0,0 +1,68 @@
1
+ # Allow Claude to connect
2
+
3
+ Connecting Claude to the instance lets the publisher draft, test, and schedule posts by asking for them in Claude. Claude works through the same API as the dashboard, and its changes show as the publisher's, via Claude.
4
+
5
+ In this guide, you turn on Cloudflare's Managed OAuth for the Access application that locks the dashboard, then connect Claude, or send the publisher the address to connect to.
6
+
7
+ To run a script or scheduled job with no one present, use a service token instead, as [Automate with a service token](02-automate.md) describes.
8
+
9
+ ## Before you begin
10
+
11
+ You need:
12
+
13
+ - The dashboard locked with Access, from [Lock the dashboard with Access](../get-started/03-access.md).
14
+ - A deployed instance. Claude Desktop and claude.ai reach it from Anthropic's servers, so they can't reach one running on your own computer.
15
+
16
+ ## 1. Turn on Managed OAuth
17
+
18
+ Managed OAuth lets an app such as Claude act with a person's account, the way the editor does in their browser. Cloudflare marks it as beta, and its [Managed OAuth](https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/managed-oauth/) page describes it.
19
+
20
+ 1. In **Zero Trust**, go to **Access controls → Applications**, and select **Edit** on your application.
21
+
22
+ 1. On the **Additional settings** tab, select the **OAuth** filter, and find **Managed OAuth (Beta)**.
23
+
24
+ 1. Turn on **Managed OAuth**.
25
+
26
+ 1. Turn on **Allow localhost clients** and **Allow loopback clients**. Claude Code receives the person's approval on their own computer, at a `localhost` or `127.0.0.1` address.
27
+
28
+ 1. Under **Allowed redirect URIs**, add Claude's address, so Claude Desktop and claude.ai can connect:
29
+
30
+ ```
31
+ https://claude.ai/api/mcp/auth_callback
32
+ ```
33
+
34
+ Access accepts Claude only with an address listed here. Leave it out if no one will connect Claude Desktop or claude.ai.
35
+
36
+ 1. Set the **Grant session duration** to 1 week. A connection renews itself while it's in use until this ends, and then Claude asks the person to approve it again. Cloudflare recommends one to two weeks for a command-line tool.
37
+
38
+ 1. Save the application.
39
+
40
+ 1. Check that a request with no sign-in now says where an app signs in:
41
+
42
+ ```bash
43
+ curl -si https://newsletter.example.com/api/whoami
44
+ ```
45
+
46
+ It answers `401`, and its `www-authenticate` line starts with `Bearer` and names `resource_metadata`. Without Managed OAuth, Cloudflare answers with a redirect to its login page instead. A browser still gets the login page either way.
47
+
48
+ ## 2. Connect Claude, or send the publisher the address
49
+
50
+ The publisher connects at the instance's address followed by `/api/mcp`:
51
+
52
+ ```
53
+ https://newsletter.example.com/api/mcp
54
+ ```
55
+
56
+ If you're the publisher, connect now, as [Connect Claude](../publishing/08-connect-claude.md) describes. If someone else is, send them that address and a link to the same page.
57
+
58
+ ## Who can connect
59
+
60
+ Anyone the `Publishers` policy lets into the dashboard can connect Claude, since they sign in the same way. To stop someone connecting, remove them from that policy. If you set `ACCESS_ALLOWED_EMAILS`, store the list again without them: the app checks it on every request, so it takes effect at once.
61
+
62
+ ## Check it
63
+
64
+ 1. Connect Claude Desktop or claude.ai, as [Connect Claude](../publishing/08-connect-claude.md) describes. The connector lists Kestrel's tools, such as **Get the publication** and **Save a draft**.
65
+
66
+ 1. Ask Claude what the publication is called. It answers with the name the dashboard shows.
67
+
68
+ If Claude can't connect, [Claude can't connect](../publishing/06-troubleshooting.md#claude-cant-connect) lists what it reports and the fix.
@@ -0,0 +1,88 @@
1
+ # Automate with a service token
2
+
3
+ A service token lets a script or scheduled job call the API, such as a job that drafts a weekly digest. Kestrel labels these changes **a service token**, rather than with a person's name.
4
+
5
+ In this guide, you create a service token, allow it in through the Access application, give it to your script, and check that it works. To connect Claude as yourself instead, follow [Allow Claude to connect](01-allow-claude.md).
6
+
7
+ ## Before you begin
8
+
9
+ You need:
10
+
11
+ - The dashboard locked with Access, from [Lock the dashboard with Access](../get-started/03-access.md).
12
+ - The script or job that will call the API, and somewhere it keeps secrets.
13
+
14
+ ## 1. Create a service token
15
+
16
+ A service token is a Client ID and a Client Secret that a program sends with each request. It carries no email, so the app tells it apart from a person's sign-in.
17
+
18
+ 1. In **Zero Trust**, go to **Access controls → Service credentials → Service Tokens**, and select **Create Service Token**.
19
+
20
+ 1. Name it for what will use it, such as `kestrel-automation`, and choose a **Service Token Duration**. A year is a good default.
21
+
22
+ 1. Select **Generate token**.
23
+
24
+ 1. Copy the **Client ID** and the **Client Secret**. Cloudflare shows the secret only once.
25
+
26
+ ## 2. Allow the token in
27
+
28
+ The application lets in only the people its `Publishers` policy names, so the token needs a policy of its own on the same application.
29
+
30
+ 1. Under **Access controls → Applications**, select **Configure** on the application from [Lock the dashboard with Access](../get-started/03-access.md#2-create-the-access-application).
31
+
32
+ 1. Add a policy:
33
+
34
+ - **Policy name:** `Automation`
35
+ - **Action:** Service Auth
36
+ - **Include:** the **Service Token** selector, with your token
37
+
38
+ 1. Save the application.
39
+
40
+ This policy is the only thing that lets the token in. `ACCESS_ALLOWED_EMAILS` doesn't apply to it, since it has no email.
41
+
42
+ ## 3. Give the token to your script
43
+
44
+ The script sends the two values as headers on every request: `CF-Access-Client-Id` with the Client ID, and `CF-Access-Client-Secret` with the Client Secret.
45
+
46
+ 1. Store the Client ID and the Client Secret where your script reads secrets, such as its environment, your CI's secrets, or a password manager. Never commit the secret to a repository.
47
+
48
+ 1. Have the script send both headers with every request to `https://newsletter.example.com/api/…`. For example, this request saves a new draft:
49
+
50
+ ```bash
51
+ curl -s https://newsletter.example.com/api/posts \
52
+ -H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" \
53
+ -H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET" \
54
+ -H "Content-Type: application/json" \
55
+ -d '{"subject":"Weekly digest","markdown":"# This week\n\nThree things worth reading."}'
56
+ ```
57
+
58
+ ## Check it
59
+
60
+ 1. Call `GET /api/whoami` with the two headers. The app answers with a program's identity, not a person's:
61
+
62
+ ```bash
63
+ curl -s https://newsletter.example.com/api/whoami \
64
+ -H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" \
65
+ -H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET"
66
+ ```
67
+
68
+ ```
69
+ {"principal":{"kind":"service","token_id":"e367c0ffee5eb4.access","client":null},"auth":{"mode":"access"}}
70
+ ```
71
+
72
+ The `token_id` is the Client ID Zero Trust shows beside your token. If it's a different one, the script is using another token.
73
+
74
+ 1. Call `GET /api/posts`. It answers with the posts the editor shows.
75
+
76
+ 1. Open a draft in the editor, and have the script save a change to it. The editor shows that the draft was changed elsewhere, last edited by **a service token**.
77
+
78
+ If the script gets a `401`, a `403`, or a login page, check the policy from section 2, and the token's two values. The policy's action must be Service Auth, since the app refuses a login through any other kind of policy with a `401` whose `reason` is `access_unidentified`. Check that the token hasn't expired, too.
79
+
80
+ ## Renew or revoke a token
81
+
82
+ A service token stops working when its duration ends. Before then, extend it on the token under **Access controls → Service credentials → Service Tokens**, or create a new token, add it to the `Automation` policy, give it to the script, and then delete the old one.
83
+
84
+ To stop a script at once, delete its token there. Its next request is refused.
85
+
86
+ ## More information
87
+
88
+ The API reference describes every route: its method and path, the body it takes, its refusals, and an example. The editor's **API** tab shows it, and a program reads it as Markdown: `GET /api/reference.md` is the index, and `GET /api/reference/<resource>.md` holds one resource's routes in full. `GET /api/openapi.json` is the same reference as an OpenAPI 3.1 document, for a tool that imports one.
@@ -10,7 +10,7 @@ SES caps how many emails your account sends a day, and how many a second. A send
10
10
 
11
11
  1. Ask AWS for a higher daily quota and a maximum send rate of 50, as AWS's [Increasing your Amazon SES sending quotas](https://docs.aws.amazon.com/ses/latest/dg/manage-sending-quotas-request-increase.html) describes.
12
12
 
13
- 1. When AWS grants it, the SES **Account dashboard** shows your new rate. In `wrangler.jsonc`, set the `production` block's [`SES_MAX_SEND_RATE`](../reference/01-configuration.md#ses_max_send_rate) to match.
13
+ 1. When AWS grants it, the SES **Account dashboard** shows your new rate. In `wrangler.jsonc`, set the `production` block's `SES_MAX_SEND_RATE` to match, as [Configuration](../reference/01-configuration.md#vars) describes.
14
14
 
15
15
  1. Commit the change, and push it:
16
16
 
@@ -33,7 +33,7 @@ It's recommended to upgrade from about 1,000 subscribers. Past that, the Dashboa
33
33
 
34
34
  1. Upgrade your Cloudflare account to the $5 [Workers Paid plan](https://developers.cloudflare.com/workers/platform/pricing/), which has no daily limit on writes.
35
35
 
36
- 1. In `wrangler.jsonc`, add `"SUBREQUEST_BUDGET": "10000"` to the `production` block's `vars`. It tells the app it's on Workers Paid, so each minute sends far more ([`SUBREQUEST_BUDGET`](../reference/01-configuration.md#subrequest_budget)).
36
+ 1. In `wrangler.jsonc`, add `"SUBREQUEST_BUDGET": "10000"` to the `production` block's `vars`. It tells the app it's on Workers Paid, so each minute sends far more. [Configuration](../reference/01-configuration.md#vars) describes `SUBREQUEST_BUDGET`.
37
37
 
38
38
  1. Commit the change, and push it:
39
39
 
@@ -26,7 +26,7 @@ grace@example.com,subscribed,single,mailchimp,
26
26
  edsger@example.com,unsubscribed,unknown,unknown,
27
27
  ```
28
28
 
29
- [The file format](01-your-list.md#the-file-format), below, describes every column. If you use Claude Code with your instance, it can do the conversion for you, as [Working with Claude](07-working-with-claude.md) describes.
29
+ [The file format](01-your-list.md#the-file-format), below, describes every column. Claude can do the conversion for you, once you [connect it](08-connect-claude.md). In your instance's repository, Claude Code's `/migrate-list` skill asks what anything ambiguous in your old service's export means, and converts a few rows for you to check.
30
30
 
31
31
  ## 3. Import it
32
32
 
@@ -111,7 +111,7 @@ An import happens in two steps. Planning shows what the import would do to every
111
111
  The commit needs two things from you when they apply:
112
112
 
113
113
  - **Your attestation,** when anyone would be confirmed: "Everyone I'm importing as subscribed opted in to receive [your publication]. This isn't a purchased, rented, or scraped list." It is kept with the import, with who gave it and when. An import that only unsubscribes or suppresses needs none.
114
- - **Each send that would grow,** acknowledged by a person. A post goes to the list as it stands when it fires. So an import can add to the audience of a scheduled send, or of one that has just fired and not yet fixed its audience. Claude can attest for you, but can't acknowledge a growing send: you finish that import in the dashboard.
114
+ - **Each send that would grow,** acknowledged by a person. A post goes to the list as it stands when it fires. So an import can add to the audience of a scheduled send, or of one that has just fired and not yet fixed its audience. Claude can attest for you, but only you in the editor can acknowledge a growing send, and Claude never speaks as the editor, so you finish that import in the dashboard.
115
115
 
116
116
  If another import is committed after you plan, plan again. If your list changes between the plan and the commit in any other way, such as a reader unsubscribing, the commit still goes ahead. Every write checks the list as it stands, and the answer reports any row that turned out differently.
117
117
 
@@ -147,7 +147,7 @@ A subscriber an import confirmed is **confirmed**, like anyone who confirmed in
147
147
 
148
148
  ### Limits in detail
149
149
 
150
- - **Rows:** up to 10,000 in one import. On the free Workers plan's limits it's 1,000, since that plan's processing time per request can't plan and commit more. [`SUBREQUEST_BUDGET`](../reference/01-configuration.md#subrequest_budget) tells Kestrel which plan it's on.
150
+ - **Rows:** up to 10,000 in one import. On the free Workers plan's limits it's 1,000, since that plan's processing time per request can't plan and commit more. A setting on your instance tells Kestrel which plan it's on, as [As your list grows](../guides/09-as-your-list-grows.md) describes.
151
151
  - **Size:** an import request of at most 10 MB, and at most 500 bytes of `evidence` a row.
152
152
  - **The free Workers plan:** it suits a list of up to about 1,000 subscribers. Past about 5,000, one send can use up the day's database writes and stop the newsletter until midnight UTC. An import's plan recommends Workers Paid when it would leave more than 1,000 confirmed subscribers, and the Dashboard and the Subscribers screen say the same while the list is past it. A scheduled post to more than 5,000 recipients warns before it's scheduled, and its banner repeats the warning while it's scheduled. None of these stops anything. [As your list grows](../guides/09-as-your-list-grows.md) has the numbers behind them.
153
153
 
@@ -1,6 +1,6 @@
1
1
  # Name your publication
2
2
 
3
- Give your newsletter a name and a look before your first post. Set the name, tagline, and logo under **Settings → Publication identity**. They appear in every email your template prints them in, on the public pages readers see, and in the confirmation email new subscribers get.
3
+ Give your publication a name and a look before your first post. Set the name, tagline, and logo under **Settings → Publication identity**. They appear in every email your template prints them in, on the public pages readers see, and in the confirmation email new subscribers get.
4
4
 
5
5
  All three are optional, and you can change them at any time. How they appear in each email comes from your email template, which you can [edit](11-email-template.md).
6
6
 
@@ -1,69 +1,32 @@
1
1
  # Write and send a post
2
2
 
3
- You write a post in Markdown, test it, and schedule it. Scheduling locks the post and makes the exact email that will go out. Then you send yourself a test of that copy, so the email you tested is the one your list receives.
3
+ Write in Markdown, schedule it, then send yourself a test. Scheduling makes the exact email your list will get, so the test you send is what goes out.
4
4
 
5
5
  **New post**, on the Dashboard or under **Drafts**, opens a draft.
6
6
 
7
- ## Writing
7
+ ## Write
8
8
 
9
- Kestrel's editor is a Markdown editor. You write a post in Markdown, the way you would for a static site generator or with an AI assistant. Kestrel places it in your [email template](11-email-template.md). The same Markdown makes the email and the post's archive page, so you never format a post twice. **Preview** shows the email as it will look, and Kestrel saves as you type.
9
+ The editor takes Markdown, and Kestrel saves as you type. The same Markdown becomes the email, in your [email template](11-email-template.md), and the post's page in your archive. **Preview** shows the email.
10
10
 
11
- A few things to know as you write:
12
-
13
- - **A blank line starts a new paragraph.** In an email, a single line break doesn't.
14
- - **The opening lines** are the preview a mail client shows beside the subject.
15
- - **Images** go in by paste or drop, and Kestrel inserts a reference by file name, such as `![cover.jpg](cover.jpg)`. Replace the words in brackets with a short description. PNG, JPEG, WebP, or GIF, up to 5 MB each.
16
- - **Where it will go** is in **Post settings**, at the top right: whether it's in your archive, and its address, such as `newsletter.example.com/archive/spring-update`, whose slug follows the subject (below).
17
-
18
- ## Test it
19
-
20
- **Send test email** sends the real email, through the same template and code as the send, to the addresses you name. **Settings → Default test recipients** fills them in for you. Nobody on your list gets anything, nothing appears in your archive, and a test's unsubscribe link unsubscribes no one.
21
-
22
- Mail clients each draw email a little differently, so trust a test over **Preview**, especially for tables, code, and HTML. A test needs an email provider, which [Connect Amazon SES](../get-started/04-ses.md) sets up.
11
+ - A blank line starts a new paragraph.
12
+ - The opening lines are the preview a mail client shows beside the subject.
13
+ - Paste or drop an image to add it: PNG, JPEG, WebP, or GIF, up to 5 MB.
23
14
 
24
15
  ## Schedule and send
25
16
 
26
- Choose **Schedule** at the foot of the editor, pick a time under **Send at**, and confirm. The time must be at least the minimum lead away: five minutes, unless your deployment sets a different [`MIN_LEAD_SECONDS`](../reference/01-configuration.md#min_lead_seconds). Neither the dashboard nor Claude can shorten it. Times are to the minute, and one with seconds rounds up.
27
-
28
- Scheduling makes the email once, from the post, your template, and your publication's details as they are at that moment. That frozen copy is what goes out, and it's also the post's page in your archive. So the post is locked, and **Preview** and **Send test email** show the frozen copy.
29
-
30
- **Send yourself a test now.** It's the copy your list receives, and the time until the send is your window to catch a mistake, including one Claude made.
31
-
32
- Changing your template, or a publication detail it shows, re-makes the email of every scheduled post, with the same content and send time. Kestrel asks before it saves, and each re-made post then asks you to send a new test. A change is refused while a post is within the minimum lead of its send time, and Kestrel says when to try again. Posts already sending or sent never change.
33
-
34
- **Send now →**, under the time picker, is for a post you've already reviewed. It still waits out the window, scheduling the post for the minimum lead from now.
35
-
36
- ### Post settings
37
-
38
- **Post settings**, at the top right of the editor, holds two choices. Neither is part of the email, so both stay open while the post is scheduled. Nothing changes until you select **Save**.
39
-
40
- **Include in archive** is checked unless you uncheck it. Unchecked, the post goes to your list as usual but is sent as email only: it stays off your public archive and your home page, and its archive address shows nothing. Use that for a post meant for the inbox: a correction to an earlier email, a short-lived notice, or a test. The editor marks such a post **Email only**.
41
-
42
- **Archive address** is where the post's page will be in your archive. Only the last part, the slug, is yours to type; it follows the subject until you do. Each post's address is its own: if you type one another post has, or one with spaces or capitals, Kestrel says so and keeps the address as it was. If another post already has the address your subject would give, Kestrel adds a number (`spring-update-2`) and tells you, so you can choose another. The address is fixed once the post starts sending.
43
-
44
- The Schedule, Send now, and Edit schedule dialogs show the same settings under the time, so you can still change them there. A change made in a dialog applies when you schedule, send, or save, and canceling leaves the post as it was. Once a post is scheduled, its banner names where it's going.
45
-
46
- Every email links its web version through the post's own **unlisted link**, which never changes. It opens the archive page while the post is in your archive, and the page itself while it's email only. Anyone with the email can open it, but nothing lists it, and search engines are asked not to.
47
-
48
- A sent post's page under **Sent** shows its address with **Remove from archive…** or **Add to archive…**. Both ask first. Removing a post takes its archive address down, so links people shared stop working, but the address stays the post's: add it back and those links work again. Neither changes what was sent.
49
-
50
- ### Changing your mind
51
-
52
- **Cancel**, on the post's banner or its card under **Sent**, makes the post a draft again at once. Edit it, test again, and schedule it again. **Edit schedule** changes the send time, whether the post is in your archive, or both, without unlocking the post, and your tests still count. You can do either until the send time.
53
-
54
- ### Who receives it
17
+ Choose **Schedule**, pick a time under **Send at**, and confirm. The time must be at least five minutes away by default, or the lead whoever runs your instance set. That's your window to catch a mistake, and you can cancel or move the send until then. **Send now** sends after the same wait.
55
18
 
56
- A post goes to your list as it stands when it sends: every confirmed subscriber, except addresses that bounced or were blocked. A reader who complained has been unsubscribed, so isn't on it. Someone who confirms while it's scheduled receives it. Someone who unsubscribes before their email is handed to your provider doesn't, even partway through the send.
19
+ **Then send yourself a test.** Scheduling locks the post and makes its email, so **Send test email** now sends exactly what your list receives. **Settings → Default test recipients** fills in the addresses. A test needs an email provider, which [Connect Amazon SES](../get-started/04-ses.md) sets up.
57
20
 
58
- So a recipient count shown before the send is a snapshot. An import that adds subscribers while a post is scheduled adds them to its audience too, and asks you first ([Bring your existing list](01-your-list.md#3-import-it)).
21
+ To change the post, choose **Cancel** on its banner, and it's a draft again. **Edit schedule** moves the time without unlocking it.
59
22
 
60
- ### Warnings when you schedule
23
+ At the send time, the post goes to your confirmed subscribers, and its page goes up in your archive. Watch it go out under **Sent**. If a send needs you, its page says so, and [Troubleshooting](06-troubleshooting.md) covers each case.
61
24
 
62
- - **No email provider connected.** The Schedule dialog can't schedule until one is connected ([Connect Amazon SES](../get-started/04-ses.md)). If the provider goes after a post is scheduled, the post is held, stays cancelable, and goes out once one is connected.
63
- - **Daily database writes.** On Cloudflare's free Workers plan, a send to more than about 5,000 recipients may use up the day's writes. The dialog warns you without stopping you, as [Bring your existing list](01-your-list.md#limits) explains.
25
+ ## Post settings
64
26
 
65
- ### When it sends
27
+ **Post settings**, at the top right of the editor, holds two choices you can change until the post sends:
66
28
 
67
- At the send time, the post moves to its send page under **Sent**, where you can watch it go out. From then it can't be edited, canceled, or moved. Its page goes up at the same moment, showing exactly the copy that was sent, so every email's "view in browser" link works. Whatever interrupts a send, such as your provider's daily quota running out, it waits and resumes where it stopped. No reader gets the same post twice.
29
+ - **Include in archive.** Uncheck it to send the post as email only, for a correction or a short notice. It stays off your archive and home page.
30
+ - **Archive address.** It follows the subject until you type your own. It's fixed once the post sends.
68
31
 
69
- If a send needs your attention, the send page and the dashboard say so. [Troubleshooting](06-troubleshooting.md) covers each case.
32
+ After a post sends, its page under **Sent** can take it out of your archive or put it back.
@@ -8,13 +8,13 @@ When a send needs you, its page and the dashboard's health block say so in a red
8
8
 
9
9
  A problem with your email provider never marks anyone unsent. The send waits where it is, and carries on from there once the problem clears. "No one has been marked unsent because of this" means this problem: readers whose own email failed earlier stay unsent.
10
10
 
11
- - **"The provider is refusing the account".** Your provider won't take mail from your account, and its own words follow, with the fix and who makes it. The fix is almost always in your Amazon SES account or Kestrel's deployment, made by whoever runs it. [What SES refuses, and the fix](#what-ses-refuses-and-the-fix), below, lists each one. Any other post that comes due meanwhile waits with it, without asking SES, and you're told once. While a quota frees up a few at a time, or while the SES sandbox takes only your verified addresses, SES takes some readers on each retry and refuses the rest: those it takes are sent, and the line stays, with the time it began, until SES takes everyone it's asked to.
12
- - **"The provider is refusing this send".** SES refuses this post's email itself, or a batch of it as invalid, rather than your account. Its words follow. Other posts go out as usual. If SES's words name a reader's address, remove that subscriber: the send skips them and carries on at its next retry. If SES refuses the email itself, the email can't change once its send has started, so whoever runs the deployment asks AWS Support why, quoting SES's words.
11
+ - **"The provider is refusing the account".** Your provider won't take mail from your account, and its own words follow, with the fix and who makes it. The fix is almost always in your Amazon SES account or your instance, made by whoever runs it. [What SES refuses, and the fix](#what-ses-refuses-and-the-fix), below, lists each one. Any other post that comes due meanwhile waits with it, without asking SES, and you're told once. While a quota frees up a few at a time, or while the SES sandbox takes only your verified addresses, SES takes some readers on each retry and refuses the rest: those it takes are sent, and the line stays, with the time it began, until SES takes everyone it's asked to.
12
+ - **"The provider is refusing this send".** SES refuses this post's email itself, or a batch of it as invalid, rather than your account. Its words follow. Other posts go out as usual. If SES's words name a reader's address, remove that subscriber: the send skips them and carries on at its next retry. If SES refuses the email itself, the email can't change once its send has started, so whoever runs your instance asks AWS Support why, quoting SES's words.
13
13
  - **"No email provider is connected".** The send is held until one is connected ([Connect Amazon SES](../get-started/04-ses.md)), and goes out once it is.
14
14
  - **"The provider is unavailable".** An outage or a rate limit on the provider's side. The send reads **Backing off** and says when it next retries, spacing its retries out up to an hour apart while the outage lasts, so there's nothing to do. Any other post that comes due meanwhile waits with it.
15
- - **"The provider keeps refusing for its sending rate".** An amber line. SES has refused the first request of several retries in a row for its sending rate, which a short burst never does, so Kestrel is set to send faster than your SES account allows. Whoever runs the deployment sets `SES_MAX_SEND_RATE` to the **Maximum send rate** on the SES console's **Account dashboard** (1 a second in the sandbox), and deploys, as [Connect Amazon SES](../get-started/04-ses.md#8-set-the-provider-and-deploy) describes. The send retries on its own meanwhile.
16
- - **"Nothing has gone out for …".** A send has handed off no one for half an hour, with no retry or provider problem to explain it. The part of Kestrel that sends has stopped or keeps failing on this send, or another send going out at the same time is taking all of its turn. A large send going out slowly never shows this. If another send is going out, this one picks up once it finishes. Read the app's logs, as [Read the logs](../get-started/05-verify.md#7-read-the-logs) describes, and check the Worker's trigger, as [`triggers.crons`](../reference/01-configuration.md#triggerscrons) describes. The send carries on from where it stopped once it runs again, mailing no one twice.
17
- - **"The fire time passed … and the send has not started".** The Cloudflare Cron Trigger that starts sends on time isn't running. The send goes out as soon as it runs again. Check the Worker's trigger, as [`triggers.crons`](../reference/01-configuration.md#triggerscrons) describes.
15
+ - **"The provider keeps refusing for its sending rate".** An amber line. SES has refused the first request of several retries in a row for its sending rate, which a short burst never does, so Kestrel is set to send faster than your SES account allows. Whoever runs your instance sets `SES_MAX_SEND_RATE` to the **Maximum send rate** on the SES console's **Account dashboard** (1 a second in the sandbox), and deploys, as [Connect Amazon SES](../get-started/04-ses.md#8-set-the-provider-and-deploy) describes. The send retries on its own meanwhile.
16
+ - **"Nothing has gone out for …".** A send has handed off no one for half an hour, with no retry or provider problem to explain it. The part of Kestrel that sends has stopped or keeps failing on this send, or another send going out at the same time is taking all of its turn. A large send going out slowly never shows this. If another send is going out, this one picks up once it finishes. Read the app's logs, as [Read the logs](../get-started/05-verify.md#7-read-the-logs) describes, and check the Worker's trigger, as [Configuration](../reference/01-configuration.md#bindings-and-triggers) describes. The send carries on from where it stopped once it runs again, mailing no one twice.
17
+ - **"The fire time passed … and the send has not started".** The Cloudflare Cron Trigger that starts sends on time isn't running. The send goes out as soon as it runs again. Check the Worker's trigger, as [Configuration](../reference/01-configuration.md#bindings-and-triggers) describes.
18
18
  - **"The provider never answered for …".** Rarely, Kestrel can't tell whether your provider took some emails, and sending again could mail those readers twice. **Resolve…** on the send's page asks you to decide. **Assume not sent** is the safe default: those readers may miss this post, but get your next one. Choose **Assume sent** only when you know they got it.
19
19
 
20
20
  ## Before a send
@@ -44,18 +44,18 @@ Delivered always means confirmed, not just accepted by your provider. Confirmati
44
44
 
45
45
  ### What SES refuses, and the fix
46
46
 
47
- When SES refuses your account or a send, the red line carries SES's words and the fix. Almost every fix is made by whoever runs the deployment, in the SES console or the deployment's settings.
47
+ When SES refuses your account or a send, the red line carries SES's words and the fix. Almost every fix is made by whoever runs your instance, in the SES console or its settings.
48
48
 
49
49
  | SES says | What it means | The fix |
50
50
  | --- | --- | --- |
51
51
  | Email address is not verified, naming a reader | The SES account is still in the sandbox, which mails only verified addresses. | Request production access on the SES console's **Account dashboard**, as [Connect Amazon SES](../get-started/04-ses.md#5-request-production-access) describes. Signups fail until then, and you're emailed about it once a day. |
52
- | Email address is not verified, naming your From address or its domain | The sending identity isn't verified in this SES region. | Verify the From address or its domain in SES, in the region the deployment sends from. |
52
+ | Email address is not verified, naming your From address or its domain | The sending identity isn't verified in this SES region. | Verify the From address or its domain in SES, in the region your instance sends from. |
53
53
  | Mail FROM domain is not verified | SES can't read the custom MAIL FROM domain's MX record. | Check that record in DNS, or set the identity's **Behavior on MX failure** to use the default MAIL FROM domain. |
54
54
  | Daily message quota exceeded | The 24-hour sending quota is spent. It frees up on its own. | In the sandbox (200 a day), request production access. Otherwise, ask AWS for a higher quota, as [As your list grows](../guides/09-as-your-list-grows.md) describes. |
55
55
  | Sending paused (account) | AWS paused the account under review, or sending was turned off by hand. | Follow AWS's email to the AWS account's address, or turn sending back on. |
56
56
  | Sending paused (configuration set) | Sending is turned off on the configuration set, by hand or by an alarm. | Turn sending back on for that set. |
57
57
  | Account suspended | AWS shut down the account's sending. | Only AWS can lift it: open a case with AWS Support. |
58
- | An access key, signature, or permission error | The deployment's SES key is wrong, expired, or lacks permission. | Replace the key, or give it `ses:SendEmail` and `ses:SendBulkEmail`. |
58
+ | An access key, signature, or permission error | Your instance's SES key is wrong, expired, or lacks permission. | Replace the key, or give it `ses:SendEmail` and `ses:SendBulkEmail`. |
59
59
  | A subscription is needed (OptInRequired) | The AWS account isn't signed up for SES. | Enable SES for the account, or settle its activation or billing with AWS. |
60
60
  | Configuration set does not exist | `SES_CONFIGURATION_SET` names a set that isn't in this region. | Fix the name, or create the set. |
61
61
  | Sending pool not found | The configuration set uses a dedicated IP pool that doesn't exist. | Pick an existing pool in the set's sending options, or remove it. |
@@ -93,6 +93,17 @@ Kestrel refuses a file it can't read with certainty, and says what's wrong and w
93
93
 
94
94
  [The file format](01-your-list.md#the-file-format) describes what each column takes.
95
95
 
96
+ ## Claude can't connect
97
+
98
+ [Connect Claude](08-connect-claude.md) covers connecting it. When that fails, find what Claude or the dashboard says:
99
+
100
+ - **Claude says it couldn't connect, or the sign-in failed.** Your instance's Access application needs Managed OAuth set up as [Turn on Managed OAuth](../guides/01-allow-claude.md#1-turn-on-managed-oauth) describes, including Claude's sign-in address for Claude Desktop and claude.ai. Check that the connector's address is your publication's address followed by `/api/mcp`, and that your instance is deployed, not running on your own computer.
101
+ - `kestrel login` says your instance "doesn't offer a sign-in for programs". Managed OAuth is off. Turn it on, as the same section describes.
102
+ - `kestrel login` or `kestrel api` reports a `401`. Access signed you in, but Kestrel refused the sign-in. If you set `ACCESS_ALLOWED_EMAILS`, check that it includes the address you signed in with. If `kestrel login` names `access_unidentified` after the `401`, or `kestrel api` prints `reason: access_unidentified`, it's the next problem: both then say that Access sent neither an email address nor a service token. If `kestrel api` says your sign-in has ended, sign in again as [Connect Claude](08-connect-claude.md#claude-code) describes.
103
+ - **"Access let you in, but didn't say who you are".** The editor shows this, and the API refuses with a `401` whose `reason` is `access_unidentified`, when your login carried neither an email nor a service token. Some other kind of Access policy let it through, such as a certificate or device rule, or a login method that gives no email. Kestrel accepts only an Allow policy on people's emails and a Service Auth policy on a service token. Sign out, then sign in through the `Publishers` policy. Whoever runs your instance removes the other policy from its Access application, or changes it to one of those two kinds.
104
+ - **Claude's changes read "via the API".** A request made with your sign-in named no client, such as one from Claude calling the API some way other than `kestrel api`, which names Claude Code. Your instance's instructions for Claude Code have it use `kestrel api`, once they're brought up to date, as [Install the release](../guides/07-upgrade.md#2-install-the-release) describes. Changes reading "a service token" mean Claude still uses one, and [Connect Claude](08-connect-claude.md#claude-code) signs it in as you instead.
105
+ - A script gets a `401` whose `reason` is `access_unidentified`. Access let the script in through a policy Kestrel doesn't accept. It needs a service token under a Service Auth policy, as [Allow the token in](../guides/02-automate.md#2-allow-the-token-in) describes.
106
+
96
107
  ## Notifications
97
108
 
98
109
  With an address set, Kestrel emails you:
@@ -101,7 +112,7 @@ With an address set, Kestrel emails you:
101
112
  - when your provider refuses your account or a send, or no provider is connected
102
113
  - when the SES sandbox refuses a signup's confirmation, at most once a day
103
114
  - when a send stops going out with nothing to explain it, waits for you to resolve it, or misses its time
104
- - when Claude imports a list for you
115
+ - when a service token imports a list, since no one saw it happen (an import you make yourself, or through Claude, sends nothing)
105
116
 
106
117
  Each links to the page it's about, and is sent once while the problem lasts. A bounce spike shows on the dashboard but isn't emailed. **Send a test notification** checks that one arrives.
107
118