@kurtbruns/kestrel 1.13.0 → 1.14.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 (39) hide show
  1. package/CHANGELOG.md +35 -1
  2. package/README.md +1 -1
  3. package/bin/kestrel.mjs +17 -2
  4. package/cli/api.mjs +462 -0
  5. package/cli/credentials.mjs +192 -0
  6. package/cli/dev-api.mjs +16 -10
  7. package/cli/login.mjs +155 -0
  8. package/cli/oauth.mjs +368 -0
  9. package/cli/openapi.mjs +569 -0
  10. package/dist/public/dashboard/app.js +290 -285
  11. package/dist/public/dashboard/app.js.map +4 -4
  12. package/dist/public/dashboard/index.html +2 -2
  13. package/dist/public/dashboard/styles.css +74 -40
  14. package/dist/worker/index.js +2723 -1192
  15. package/docs/README.md +3 -2
  16. package/docs/get-started/02-deploy.md +17 -20
  17. package/docs/get-started/03-access.md +10 -8
  18. package/docs/get-started/06-go-live.md +2 -1
  19. package/docs/guides/01-allow-claude.md +68 -0
  20. package/docs/guides/02-automate.md +88 -0
  21. package/docs/guides/09-as-your-list-grows.md +2 -2
  22. package/docs/publishing/01-your-list.md +3 -3
  23. package/docs/publishing/03-name-your-publication.md +1 -1
  24. package/docs/publishing/04-write-a-post.md +15 -52
  25. package/docs/publishing/06-troubleshooting.md +20 -9
  26. package/docs/publishing/08-connect-claude.md +60 -0
  27. package/docs/publishing/09-share-your-subscribe-link.md +1 -1
  28. package/docs/publishing/10-check-your-setup.md +9 -16
  29. package/docs/publishing/11-email-template.md +1 -1
  30. package/docs/publishing/README.md +2 -2
  31. package/docs/reference/01-configuration.md +132 -85
  32. package/docs/reference/02-sending-domain-dns.md +2 -2
  33. package/migrations/0013_who_acted_client.sql +20 -0
  34. package/migrations/0014_send_acts.sql +25 -0
  35. package/package.json +1 -1
  36. package/template/README.md +2 -0
  37. package/template/claude/CLAUDE.md +8 -5
  38. package/docs/guides/01-connect-claude.md +0 -101
  39. package/docs/publishing/07-working-with-claude.md +0 -29
@@ -0,0 +1,60 @@
1
+ # Connect Claude
2
+
3
+ Connecting Claude to Kestrel lets you publish by asking Claude. It drafts, tests, and schedules posts as you, and its changes show as yours, via Claude.
4
+
5
+ **Your Kestrel instance has to allow this first,** as [Allow Claude to connect](../guides/01-allow-claude.md) describes.
6
+
7
+ How you connect depends on where you talk to Claude:
8
+
9
+ - **In Claude Desktop or claude.ai,** add Kestrel as a connector.
10
+ - **In Claude Code,** add Kestrel as a connector, or sign in with the Kestrel CLI to use the API directly.
11
+
12
+ ## Claude Desktop or claude.ai
13
+
14
+ 1. In Claude, go to **Customize → Connectors**, select **Add**, then **Add custom connector**.
15
+ 1. Name it, and enter your publication's address followed by `/api/mcp`, such as `https://newsletter.example.com/api/mcp`.
16
+ 1. Under **OAuth client**, choose **Register automatically**.
17
+ 1. Select **Add**, then sign in the way you do for the dashboard, and approve the sign-in.
18
+
19
+ On a Team or Enterprise plan, an Owner of your Claude organization adds the connector under **Organization settings → Connectors** instead, and you then select **Connect** on it. Anthropic's [custom connectors guide](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp) has the details.
20
+
21
+ To check it works, ask Claude what your publication is called.
22
+
23
+ ## Claude Code
24
+
25
+ Claude Code can use the same connector as Claude Desktop and claude.ai. To reach the whole API instead, images included, sign Claude Code in with the Kestrel CLI:
26
+
27
+ 1. Run this, with your publication's address:
28
+
29
+ ```bash
30
+ npx -p @kurtbruns/kestrel kestrel login https://newsletter.example.com
31
+ ```
32
+
33
+ 1. Approve the sign-in in the browser that opens. The terminal then says who you're signed in as.
34
+
35
+ The sign-in stays on your computer. Opened in your instance's repository, Claude Code then calls Kestrel's API as you through `kestrel api`, the way `gh` calls GitHub, following your instance's instructions for Claude. Its changes show as yours, via Claude Code.
36
+
37
+ ## How long a sign-in lasts
38
+
39
+ The sign-in renews itself while you use Claude, until your instance's grant session ends, a week if it follows the setup guide. Then Claude asks you to approve a new one.
40
+
41
+ To disconnect, remove the connector under **Customize → Connectors**. To sign out of the Kestrel CLI, run `npx -p @kurtbruns/kestrel kestrel logout`.
42
+
43
+ ## Working with Claude
44
+
45
+ Ask Claude in plain words, the way you'd ask a colleague, and ask it what it can do: it knows its tools, and reads these pages from Kestrel. For example:
46
+
47
+ - "Proofread my draft titled 'First frost' and schedule it to go out Friday at 7am."
48
+ - "Add jack@example.com to the list."
49
+ - "Import the subscribers in this CSV from my old newsletter."
50
+
51
+ Whatever Claude is asked, Kestrel holds these for any client:
52
+
53
+ - **Every send waits out the review window,** and you can cancel it until it goes out.
54
+ - **What goes out is exactly the copy that was scheduled.** A scheduled post is locked.
55
+ - **No one is mailed twice,** and only confirmed subscribers receive a post.
56
+ - **An unsubscribe is final** until the reader subscribes again.
57
+ - **No one's edit is overwritten.** A save based on an older version is refused.
58
+ - **Only you can let an import grow a scheduled send,** in the dashboard.
59
+
60
+ Changes Claude makes show in the dashboard as yours, such as "Scheduled by you@example.com via Claude".
@@ -1,6 +1,6 @@
1
1
  # Share your subscribe link
2
2
 
3
- Your publication's subscribe page is at `/subscribe` on your Kestrel's address, such as `https://newsletter.example.com/subscribe`. Copy it from the dashboard's checklist or **Settings → Ways to subscribe**, and share it wherever your readers are. A reader joins only once they confirm by email.
3
+ Your publication's subscribe page is at `/subscribe` on your publication's address, such as `https://newsletter.example.com/subscribe`. Copy it from the dashboard's checklist or **Settings → Ways to subscribe**, and share it wherever your readers are. A reader joins only once they confirm by email.
4
4
 
5
5
  **Signups open only once an email provider is connected.** Until then, the page says the newsletter isn't taking subscribers yet, and the dashboard says so too. You can share the link anyway, and it opens once email is connected ([Connect Amazon SES](../get-started/04-ses.md)).
6
6
 
@@ -1,30 +1,23 @@
1
1
  # Check your setup
2
2
 
3
- Before your readers get anything, see it arrive yourself, and make sure a problem will reach you without your watching for it. Send yourself a test and reply to it, subscribe yourself, and set where Kestrel emails you.
3
+ Before your readers get anything, check three things yourself: your email arrives and replies reach you, subscribing works, and problems will reach you.
4
4
 
5
- If you're bringing a list of more than about 1,000 subscribers, upgrade to Cloudflare's Workers Paid plan before you import it. Workers Free 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. [As your list grows](../guides/09-as-your-list-grows.md) walks through the upgrade.
5
+ **Email has to be connected first.** Until it is, nothing can be sent, and the dashboard's checklist shows **Connect Amazon SES** in place of this step, as [Connect Amazon SES](../get-started/04-ses.md) describes.
6
6
 
7
- ## Send yourself a test
7
+ ## 1. Send yourself a test
8
8
 
9
9
  Choose **Template** in the sidebar, then **Send test email**, and send a sample post to your own inboxes. When it arrives, check:
10
10
 
11
- - **The From name and address,** which readers see first. They're set when Kestrel is deployed ([Connect Amazon SES](../get-started/04-ses.md#8-set-the-provider-and-deploy)).
11
+ - **The From name and address,** which readers see first. They're set when your instance is deployed.
12
12
  - **Where it lands:** the inbox, Promotions, or spam. If it lands in spam, check your sending domain's records, as [Sending-domain DNS](../reference/02-sending-domain-dns.md) describes.
13
13
  - **A reply.** Reply to the test, and check that it reaches your inbox. Kestrel never sees replies, so this depends on [forwarding your From address](../get-started/04-ses.md#3-receive-replies-at-your-from-address).
14
14
 
15
- ## Subscribe yourself
15
+ ## 2. Subscribe yourself
16
16
 
17
- Open your subscribe page, from the link under **Settings → Ways to subscribe**, and subscribe with a personal address. Then confirm. You see what a new reader sees, and you then appear under **Subscribers** as **Confirmed**. [Share your subscribe link](09-share-your-subscribe-link.md) covers the page and the confirmation email.
17
+ Open your subscribe page from **Settings → Ways to subscribe**, subscribe with a personal address, and confirm. You see what a new reader sees, and you appear under **Subscribers** as **Confirmed**.
18
18
 
19
- ## Set your notifications address
19
+ ## 3. Set your notifications address
20
20
 
21
- Under **Settings → Notifications**, fill in **Send notifications to**, save, and choose **Send a test notification**. Kestrel then emails you when a send finishes or needs you. [Notifications](06-troubleshooting.md#notifications) lists what you're told, and why a second way to send them is worth asking for.
22
-
23
- ## If email isn't connected
24
-
25
- Until an email provider is connected, nothing can be sent, and the dashboard says so. The checklist shows **Connect Amazon SES** in place of this step, as [Connect Amazon SES](../get-started/04-ses.md) describes. **Settings → Email sender** shows your From address, sending domain, and provider, read-only.
26
-
27
- ## As your list grows
28
-
29
- A growing list meets your Amazon SES daily sending quota, longer sends, and on Cloudflare's free Workers plan, a cap on daily database writes. Kestrel tells you when you reach each one (on the free plan, from about 1,000 subscribers), and [As your list grows](../guides/09-as-your-list-grows.md) covers raising each.
21
+ Under **Settings → Notifications**, fill in **Send notifications to**, save, and choose **Send a test notification**. Kestrel then emails you when a send finishes or needs you, as [Notifications](06-troubleshooting.md#notifications) lists.
30
22
 
23
+ **If you're bringing more than about 1,000 subscribers, upgrade to Cloudflare's Workers Paid plan before you import.** [Bring your existing list](01-your-list.md#limits) explains why.
@@ -33,4 +33,4 @@ A variable is replaced with its value in each email. Type it exactly as shown, o
33
33
 
34
34
  **Send test email** on the **Template** page sends a sample post through your saved template, so you see it in a real inbox. With unsaved edits, the button reads **Save & send test**, since a test always uses the saved template.
35
35
 
36
- A change to the template re-makes the email of every scheduled post, as [Schedule and send](04-write-a-post.md#schedule-and-send) describes.
36
+ A change to the template, or to 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 its last few minutes before sending. Posts already sending or sent never change.
@@ -4,7 +4,7 @@ These pages get you publishing in the dashboard, and cover the decisions to make
4
4
 
5
5
  ## Set up your publication
6
6
 
7
- The steps to set up a new publication, in the order the dashboard's setup checklist lists them. Once you've started a post, they stay on the dashboard as **Finish setting up** until you publish your first post, one that goes to your subscribers and onto your site. That's the fourth step.
7
+ These are the steps to set up your publication. If you're starting without a list, skip importing one.
8
8
 
9
9
  1. [Name your publication](03-name-your-publication.md)
10
10
  1. [Check your setup](10-check-your-setup.md)
@@ -17,7 +17,7 @@ The steps to set up a new publication, in the order the dashboard's setup checkl
17
17
  Things you choose to do once you're running.
18
18
 
19
19
  - [Edit your email template](11-email-template.md)
20
- - [Working with Claude](07-working-with-claude.md)
20
+ - [Connect Claude](08-connect-claude.md)
21
21
 
22
22
  ## Rules and troubleshooting
23
23
 
@@ -10,142 +10,189 @@ In local development, `npm run dev` reads the top level of `wrangler.jsonc` and
10
10
 
11
11
  None of these can be read or changed from inside the app, by the editor or by Claude. Settings in the editor shows the public ones, read-only.
12
12
 
13
+ This is the `production` block a new instance starts with. [Deploy the app](../get-started/02-deploy.md) fills in its database and hostnames:
14
+
15
+ ```jsonc
16
+ "production": {
17
+ "name": "kestrel-production",
18
+ "routes": [{ "pattern": "newsletter.example.com", "custom_domain": true }],
19
+ "workers_dev": false,
20
+ "preview_urls": false,
21
+ "triggers": { "crons": ["* * * * *"] },
22
+ "observability": { "enabled": true },
23
+ "d1_databases": [
24
+ {
25
+ "binding": "DB",
26
+ "database_name": "kestrel-production",
27
+ "database_id": "REPLACE_WITH_PRODUCTION_D1_ID",
28
+ "migrations_dir": "migrations"
29
+ }
30
+ ],
31
+ "r2_buckets": [{ "binding": "MEDIA", "bucket_name": "kestrel-media-production" }],
32
+ "vars": {
33
+ "PROVIDER": "",
34
+ "APP_ORIGIN": "https://newsletter.example.com",
35
+ "ARCHIVE_BASE_PATH": "/archive",
36
+ "SENDING_DOMAIN": "send.example.com",
37
+ "FROM_ADDRESS": "Newsletter <newsletter@send.example.com>",
38
+ "AWS_REGION": "us-east-1"
39
+ }
40
+ }
41
+ ```
42
+
13
43
  ## Vars
14
44
 
15
- | Var | What it is | Default |
16
- | --- | --- | --- |
17
- | `PROVIDER` | the email provider: `ses`, or `fake` for local development | none: no provider connected |
18
- | `APP_ORIGIN` | the origin the app is served from: scheme and host, no path | required |
19
- | `ARCHIVE_BASE_PATH` | the path the archive lives under | `/archive` |
20
- | `FROM_ADDRESS` | the `From:` of every email | required with a real provider |
21
- | `SENDING_DOMAIN` | the sending domain, shown in Settings | none |
22
- | `AWS_REGION` | the SES region | `us-east-1` in the template, required with SES |
23
- | `SES_MAX_SEND_RATE` | your SES account's maximum send rate, in messages a second | `14` |
24
- | `SUBREQUEST_BUDGET` | how many subrequests one minute's send work may use | `50` |
25
- | `MIN_LEAD_SECONDS` | the least time every send stays cancelable before it goes out | `300` |
26
- | `ARCHIVE_ORIGIN` | the origin archive links use, to put the archive on your website | `APP_ORIGIN` |
27
- | `MEDIA_PUBLIC_BASE` | the base URL images are served from | `APP_ORIGIN` + `/media` |
28
- | `NOTIFY_FROM` | the sender of notifications through Cloudflare's email | `Kestrel <kestrel@` + the app's hostname + `>` |
29
- | `ALLOW_FAKE_PROVIDER` | `true` lets `fake` run on a real hostname, for a demo that mails no one on purpose | unset |
45
+ Vars go in the environment block's `vars`. Every value is a string.
46
+
47
+ - `PROVIDER` optional
48
+
49
+ The email provider: `ses`, or `fake` for local development. Default: none, so no provider is connected. Refused unless it's exactly `ses`, `fake`, or `none`: a misspelling, or `SES`, is never taken for another. `resend`, which earlier releases supported, is refused with a pointer to [Connect Amazon SES](../get-started/04-ses.md#switch-an-instance-that-already-sends).
50
+
51
+ Until it's set, or while it's empty, the app runs with no provider connected. Posts can be written and previewed, but everything that would send email is refused: scheduling, sending now, test emails, and the confirmation emails that signups need. The subscribe page says signups aren't open yet, and Settings shows **No email provider connected**. Setting it to `none` means the same. A new instance's `production` block declares it empty, so wrangler doesn't warn that the top level's value is missing there. [Connect Amazon SES](../get-started/04-ses.md) changes it to `ses`.
52
+
53
+ `fake` delivers nothing. It records a send as if every recipient accepted it. Local development runs on it, and names it on purpose in the top level of `wrangler.jsonc`. Never copy it into `production`, whatever wrangler suggests: every subscriber would be recorded as sent the post, and never get it. The app refuses `fake` unless `APP_ORIGIN` is on `localhost` or `127.0.0.1`, since on a real hostname it would open signups whose confirmations go nowhere.
54
+
55
+ - `ALLOW_FAKE_PROVIDER` optional
56
+
57
+ `true` lets `fake` run on a real hostname, for a hosted demo that mails no one on purpose. No other deployment needs it. With `fake`, it must be `true` when set; every other provider ignores it.
58
+
59
+ - `APP_ORIGIN` required
30
60
 
31
- ### `PROVIDER`
61
+ The origin the app is served from: scheme and host, no path. Refused unless it's a plain `http` or `https` origin with no path, and never on `example.com` or a name under it, unless `PROVIDER` is `fake`. A trailing slash is dropped.
32
62
 
33
- Until it's set, or while it's empty, the app runs with no provider connected. Posts can be written and previewed, but everything that would send email is refused: scheduling, sending now, test emails, and the confirmation emails that signups need. The subscribe page says signups aren't open yet, and Settings shows **No email provider connected**. Setting it to `none` means the same. A new instance's `production` block declares it empty, `"PROVIDER": ""`, so wrangler doesn't warn that the top level's value is missing there. [Connect Amazon SES](../get-started/04-ses.md) changes it to `ses`.
63
+ - `AWS_REGION` required with SES
34
64
 
35
- `fake` delivers nothing. It records a send as if every recipient accepted it. Local development runs on it, and names it on purpose in the top level of `wrangler.jsonc`. Never copy it into `production`, whatever wrangler suggests: every subscriber would be recorded as sent the post, and never get it. The app refuses `fake` unless `APP_ORIGIN` is on `localhost` or `127.0.0.1`. A hosted demo that runs `fake` on a real hostname on purpose sets `ALLOW_FAKE_PROVIDER` to `true`, which no other deployment needs, and which no other provider reads.
65
+ The SES region. The template sets `us-east-1`.
36
66
 
37
- ### `FROM_ADDRESS` and `SENDING_DOMAIN`
67
+ - `FROM_ADDRESS` required with a real provider
38
68
 
39
- `FROM_ADDRESS` is the only address mail is sent from, and the identity your provider authenticates. `SENDING_DOMAIN` is for display: Settings shows it beside the From address. The app never checks that the two match, so set `SENDING_DOMAIN` to the part of `FROM_ADDRESS` after the `@`.
69
+ The `From:` of every email, and the identity your provider authenticates. Refused unless it's an email address, optionally with a display name in front, like `Name <address>`, and never on `example.com`, unless `PROVIDER` is `fake`.
40
70
 
41
- Neither one is the publication's name, tagline, or logo. You set those in the editor, under **Settings → Publication identity**. Until you do, the From address's display name stands in for the name, and with neither, the emails and reader pages show no name at all.
71
+ It isn't the publication's name, tagline, or logo. You set those in the editor, under **Settings → Publication identity**. Until you do, the From address's display name stands in for the name, and with neither, the emails and reader pages show no name at all.
42
72
 
43
- ### `ARCHIVE_BASE_PATH`
73
+ - `SENDING_DOMAIN` optional
44
74
 
45
- A listed post's archive address is `ARCHIVE_BASE_PATH` followed by the post's slug. Emails link through each post's unlisted link, `/email/<token>` on `APP_ORIGIN`, which forwards to the archive address, so changing the prefix later breaks no email that carries one, only archive addresses people have shared. Emails sent by 1.11 or earlier carry the archive address itself, and changing the prefix breaks those. To keep links from an older install working, set it to the prefix they use, such as `/newsletter`.
75
+ The sending domain, shown in Settings beside the From address. The app never checks that it matches `FROM_ADDRESS`, so set it to the part of `FROM_ADDRESS` after the `@`. Never on `example.com`, unless `PROVIDER` is `fake`.
46
76
 
47
- A missing leading slash is added, and a trailing slash is dropped.
77
+ - `ARCHIVE_BASE_PATH` optional
48
78
 
49
- ### `SES_MAX_SEND_RATE`
79
+ The path the archive lives under. Default: `/archive`. A missing leading slash is added, and a trailing slash is dropped.
50
80
 
51
- The app sends SES one request a second, the most SES takes, and each request carries one second of this rate, up to 50 recipients. Set it to the maximum send rate on your SES **Account dashboard**. To send faster, ask AWS to raise that rate to 50 before you move to Workers Paid: on Workers Free, a rate of 50 sends nearly four times as many recipients a minute as 14, and a higher rate adds nothing there. That's `1` in the sandbox, and whatever AWS grants after that. The default, 14, is a common starting rate, not your account's.
81
+ A listed post's archive address is `ARCHIVE_BASE_PATH` followed by the post's slug. Emails link through each post's unlisted link, `/email/<token>` on `APP_ORIGIN`, which forwards to the archive address, so changing the prefix later breaks no email that carries one, only archive addresses people have shared. Emails sent by 1.11 or earlier carry the archive address itself, and changing the prefix breaks those. To keep links from an older install working, set it to the prefix they use, such as `/newsletter`.
52
82
 
53
- A rate set too high only means SES throttles some requests. Those recipients wait for the next minute, and no one is mailed twice.
83
+ - `SES_MAX_SEND_RATE` optional
54
84
 
55
- ### `SUBREQUEST_BUDGET`
85
+ Your SES account's maximum send rate, in messages a second. Default: `14`, a common starting rate, not your account's. Refused unless it's a whole number above zero.
56
86
 
57
- Cloudflare caps how many subrequests one run of a Worker may make. Database queries and outbound requests both count. It also caps database queries alone: 50 a run on Workers Free, and 1,000 on Workers Paid. Each minute's send work is one run, so a large send goes out over several minutes, each stopping short of both caps.
87
+ The app sends SES one request a second, the most SES takes, and each request carries one second of this rate, up to 50 recipients. Set it to the maximum send rate on your SES **Account dashboard**: `1` in the sandbox, and whatever AWS grants after that. To send faster, ask AWS to raise that rate to 50 before you move to Workers Paid: on Workers Free, a rate of 50 sends nearly four times as many recipients a minute as 14, and a higher rate adds nothing there. A rate set too high only means SES throttles some requests. Those recipients wait for the next minute, and no one is mailed twice.
58
88
 
59
- This setting is the subrequest cap. The app keeps database queries under theirs on its own.
89
+ - `SUBREQUEST_BUDGET` optional
60
90
 
61
- - **The default, 50,** is the Workers Free limit, and safe on any plan. A minute sends four SES requests: 56 recipients at a send rate of 14, and 200 at a rate of 50 or more.
62
- - **On Workers Paid,** which allows 10,000, set `"SUBREQUEST_BUDGET": "10000"`. A minute then sends about 50 SES requests, one a second: about 700 recipients at a rate of 14, and about 2,500 at 50 or more.
63
- - **If you raised the Worker's own limit** with `limits.subrequests`, you may set this to match.
91
+ How many subrequests one minute's send work may use, which also tells the app which Workers plan it's on. Default: `50`, the Workers Free limit, and safe on any plan. Refused unless it's a whole number above zero. A value below 30 is raised to 30, the least a minute needs to send anything and still send a notification.
64
92
 
65
- It's also how the app tells Workers Free from Workers Paid. At the default, the app runs on Free's limits:
93
+ Cloudflare caps how many subrequests one run of a Worker may make. Database queries and outbound requests both count. It also caps database queries alone: 50 a run on Workers Free, and 1,000 on Workers Paid. Each minute's send work is one run, so a large send goes out over several minutes, each stopping short of both caps. This setting is the subrequest cap. The app keeps database queries under theirs on its own.
66
94
 
67
- - One import takes up to 1,000 rows, since Free's 10 ms of CPU a request can't plan and commit more. Above it, up to 10,000 ([Limits in detail](../publishing/01-your-list.md#limits-in-detail)).
68
- - An export reads 1,000 rows a page. Above it, 10,000.
69
- - Workers Free 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. So the Dashboard and the Subscribers screen recommend Workers Paid once the confirmed list passes 1,000, and so does an import plan that would take it past. The app also warns before a send to more than 5,000 recipients ([As your list grows](../guides/09-as-your-list-grows.md)). None of these stops you. Above the default, none appears, so set it on an account already on Workers Paid.
95
+ - At the default, a minute sends four SES requests: 56 recipients at a send rate of 14, and 200 at a rate of 50 or more.
96
+ - On Workers Paid, which allows 10,000, set `"SUBREQUEST_BUDGET": "10000"`. A minute then sends about 50 SES requests, one a second: about 700 recipients at a rate of 14, and about 2,500 at 50 or more.
97
+ - If you raised the Worker's own limit with `limits.subrequests`, you may set this to match.
70
98
 
71
- Never set it above your plan's limit. A minute that hits the cap is cut off mid-batch, and the send stalls until a later minute picks it up. A value below 30 is raised to 30, the least a minute needs to send anything and still send a notification.
99
+ Never set it above your plan's limit. A minute that hits the cap is cut off mid-batch, and the send stalls until a later minute picks it up.
72
100
 
73
- ### `MIN_LEAD_SECONDS`
101
+ At the default, the app runs on Workers Free's limits. One import takes up to 1,000 rows, since Free's 10 ms of CPU a request can't plan and commit more, and an export reads 1,000 rows a page. Above it, both take 10,000 ([Limits in detail](../publishing/01-your-list.md#limits-in-detail)). Workers Free 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. So the Dashboard and the Subscribers screen recommend Workers Paid once the confirmed list passes 1,000, and so does an import plan that would take it past. The app also warns before a send to more than 5,000 recipients ([As your list grows](../guides/09-as-your-list-grows.md)). None of these stops you. Above the default, none appears, so set it on an account already on Workers Paid.
74
102
 
75
- Every send stays visible and cancelable for at least this long before it goes out, whether it's scheduled, sent now, or moved. It's the window to catch a mistake, including one in a send Claude prepared.
103
+ - `MIN_LEAD_SECONDS` optional
76
104
 
77
- The floor is 60, since sends go out from a once-a-minute schedule. The ceiling is 86400, one day. It's the least wait before every send, not a limit on how far ahead a post can be scheduled. It lives in deploy config on purpose, so neither the editor nor Claude can shorten it.
105
+ The least time every send stays cancelable before it goes out, whether it's scheduled, sent now, or moved. Default: `300`, five minutes. Refused unless it's a whole number from 60 to 86400, one day.
78
106
 
79
- ### `ARCHIVE_ORIGIN` and `MEDIA_PUBLIC_BASE`
107
+ It's the window to catch a mistake, including one in a send Claude prepared. The floor is 60, since sends go out from a once-a-minute schedule. It's the least wait before every send, not a limit on how far ahead a post can be scheduled. It lives in deploy config on purpose, so neither the editor nor Claude can shorten it.
80
108
 
81
- Leave them unset to keep everything on the app's own hostname.
109
+ - `ARCHIVE_ORIGIN` optional
82
110
 
83
- `ARCHIVE_ORIGIN` points archive links at another origin, such as your website, `https://example.com`. The app doesn't answer there on its own. A Worker route on that domain, such as `example.com/archive*` in the environment's `routes`, sends the archive path to the app. Links already mailed keep working, since the app still answers on its own hostname. Crawlers read your website's own `robots.txt` for the archive pages there. The app's `/llms.txt` stays on the app's hostname, and its archive link points at your website.
111
+ The origin archive links use, to put the archive on your website. Default: `APP_ORIGIN`. Refused unless it's a plain `http` or `https` origin with no path, and never on `example.com`, unless `PROVIDER` is `fake`. A trailing slash is dropped.
84
112
 
85
- `MEDIA_PUBLIC_BASE` serves images from another base URL, such as a custom domain on the image bucket. The app's own `/media` path sends `Content-Security-Policy: sandbox` and `X-Content-Type-Options: nosniff` with every file, so a file in the bucket never runs as a page. A bucket's custom domain sends neither, so add both with a Response Header Transform Rule on that hostname.
113
+ The app doesn't answer on that origin on its own. A Worker route on that domain, such as `example.com/archive*` in the environment's `routes`, sends the archive path to the app. Links already mailed keep working, since the app still answers on its own hostname. Crawlers read your website's own `robots.txt` for the archive pages there. The app's `/llms.txt` stays on the app's hostname, and its archive link points at your website.
86
114
 
87
- ### `NOTIFY_FROM`
115
+ - `MEDIA_PUBLIC_BASE` optional
88
116
 
89
- Used only with the `NOTIFY` binding. It must be an address on a domain set up for Cloudflare's email. Without the binding, notifications come from `FROM_ADDRESS`, through your email provider.
117
+ The base URL images are served from, such as a custom domain on the image bucket. Default: `APP_ORIGIN` followed by `/media`. Refused unless it's a plain `http` or `https` URL, which may have a path, and never on `example.com`, unless `PROVIDER` is `fake`. A trailing slash is dropped.
118
+
119
+ The app's own `/media` path sends `Content-Security-Policy: sandbox` and `X-Content-Type-Options: nosniff` with every file, so a file in the bucket never runs as a page. A bucket's custom domain sends neither, so add both with a Response Header Transform Rule on that hostname.
120
+
121
+ - `NOTIFY_FROM` optional
122
+
123
+ The sender of notifications through Cloudflare's email. Default: `kestrel@` on the app's hostname, named Kestrel, such as `Kestrel <kestrel@newsletter.example.com>`. It's used only with the `NOTIFY` binding, and must be an address on a domain set up for Cloudflare's email.
90
124
 
91
125
  ## Secrets
92
126
 
93
- | Secret | What it is | Needed for |
94
- | --- | --- | --- |
95
- | `ACCESS_TEAM_DOMAIN` | your Zero Trust team domain, such as `your-team.cloudflareaccess.com`; an `https://` or trailing slash is dropped | every deployment |
96
- | `ACCESS_AUD` | the Access application's audience tag | every deployment |
97
- | `ACCESS_ALLOWED_EMAILS` | a comma-separated list of the people allowed to sign in | optional |
98
- | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | the IAM user's access key | SES |
99
- | `SNS_TOPIC_ARN` | the SNS topic the SES webhook accepts; it refuses every other topic | SES |
100
- | `SES_CONFIGURATION_SET` | the configuration set named on every email, so SES reports bounces and complaints; a name SES can't find in the region holds every list send until it's fixed | SES, recommended |
127
+ Store each with `npx wrangler secret put NAME --env production`, which prompts for the value.
128
+
129
+ - `ACCESS_TEAM_DOMAIN` required
130
+
131
+ Your Zero Trust team domain, such as `your-team.cloudflareaccess.com`. An `https://` or `http://` in front and a trailing slash are dropped, but anything else, such as a path or a port, is refused, since it would fail every sign-in. [Lock the dashboard with Access](../get-started/03-access.md#4-give-the-app-its-two-values) shows where to find it.
132
+
133
+ - `ACCESS_AUD` required
134
+
135
+ The Access application's audience tag. It's set together with `ACCESS_TEAM_DOMAIN` or not at all: with only one, the app could verify no sign-in and would let no one into the editor, so it refuses and names the missing one.
136
+
137
+ - `ACCESS_ALLOWED_EMAILS` optional
101
138
 
102
- `ACCESS_ALLOWED_EMAILS` applies to people only. A service token, such as the one Claude uses, carries no email, so the Access policy alone decides whether it gets in.
139
+ A comma-separated list of the people allowed to sign in. Without it, the app lets in anyone the Access policy allows, so it keeps the editor limited to named people if the policy is widened later. It applies to people only. A service token, the credential a script uses, carries no email, so the Access policy alone decides whether it gets in.
103
140
 
104
- Never set `DEV_AUTH_SECRET` on a deployed environment. It's local development's stand-in for Access, and a deployed instance ignores it anyway. The app honors it only when the environment looks like local development: `PROVIDER` is `fake`, Access isn't set up, and `APP_ORIGIN` is on `localhost` or `127.0.0.1`. A real provider, Access, or a real hostname each turns it off, and Access becomes the only way in.
141
+ - `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` required with SES
142
+
143
+ The IAM user's access key, which sends through SES.
144
+
145
+ - `SNS_TOPIC_ARN` required with SES
146
+
147
+ The SNS topic the SES webhook accepts. The webhook refuses a report from every other topic, and logs `webhook.refused` naming both.
148
+
149
+ - `SES_CONFIGURATION_SET` optional, recommended with SES
150
+
151
+ The configuration set named on every email, so SES reports bounces and complaints. A name SES can't find in the region holds every list send until it's fixed.
152
+
153
+ - `DEV_AUTH_SECRET` never on a deployed environment
154
+
155
+ Local development's stand-in for Access. A deployed instance ignores it anyway. The app honors it only when the environment looks like local development: `PROVIDER` is `fake`, Access isn't set up, and `APP_ORIGIN` is on `localhost` or `127.0.0.1`. A real provider, Access, or a real hostname each turns it off, and Access becomes the only way in.
105
156
 
106
157
  ## Bindings and triggers
107
158
 
108
159
  Wrangler doesn't carry bindings or vars over from the top level of `wrangler.jsonc` to an environment. Each environment's block declares its own.
109
160
 
110
- | Setting | What it is | Default |
111
- | --- | --- | --- |
112
- | `name` | the Worker's name, such as `kestrel-production` | required |
113
- | `routes` | the app's hostname, attached on deploy, and any route on your website | required |
114
- | `workers_dev`, `preview_urls` | the Worker's `workers.dev` and preview addresses | `false` in the template |
115
- | `triggers.crons` | the once-a-minute schedule, `* * * * *` | required |
116
- | `observability` | Workers Logs, where the app's log lines land | on |
117
- | `d1_databases`, binding `DB` | the database: posts, subscribers, sends, and settings | required |
118
- | `r2_buckets`, binding `MEDIA` | the bucket for images and your logo | required |
119
- | `send_email`, binding `NOTIFY` | Cloudflare's email, for notifications | none |
161
+ - `name` required
120
162
 
121
- ### `routes`, `workers_dev`, and `preview_urls`
163
+ The Worker's name, such as `kestrel-production`.
122
164
 
123
- The template turns off the `workers.dev` addresses, so the app answers only on your hostname. That's where your Access application and any rate-limiting rule apply. Each deploy replaces the Worker's routes with the ones in the file, so add a route here, never in the dashboard.
165
+ - `routes` required
124
166
 
125
- ### `triggers.crons`
167
+ The app's hostname, attached on deploy, and any route on your website. Each deploy replaces the Worker's routes with the ones in the file, so add a route here, never in the dashboard.
126
168
 
127
- The schedule runs every minute. It sends whatever is due, and the notifications about it. Without it, nothing scheduled ever goes out.
169
+ - `workers_dev` and `preview_urls` optional
128
170
 
129
- ### `observability`
171
+ The Worker's `workers.dev` and preview addresses. The template sets both to `false`, so the app answers only on your hostname. That's where your Access application and any rate-limiting rule apply.
130
172
 
131
- Every invocation is logged, with no sampling. Sampling could drop the one minute that reports a stuck or missed send.
173
+ - `triggers.crons` required
132
174
 
133
- ### `NOTIFY`
175
+ The once-a-minute schedule, `* * * * *`. It sends whatever is due, and the notifications about it. Without it, nothing scheduled ever goes out.
134
176
 
135
- When declared, notifications go through Cloudflare's email instead of your provider, so they still arrive when your provider refuses your account. Local development ignores it, and sends notifications to a stand-in that delivers nothing.
177
+ - `observability` optional
136
178
 
137
- ## What the app refuses
179
+ Workers Logs, where the app's log lines land. On in the template. Every invocation is logged, with no sampling, since sampling could drop the one minute that reports a stuck or missed send.
138
180
 
139
- The app checks its configuration on every request. It refuses to run on one that would do the wrong thing quietly:
181
+ - `DB` required
182
+
183
+ The `d1_databases` binding for the database: posts, subscribers, sends, and settings.
184
+
185
+ - `MEDIA` required
186
+
187
+ The `r2_buckets` binding for the bucket of images and your logo.
188
+
189
+ - `NOTIFY` optional
190
+
191
+ The `send_email` binding for Cloudflare's email. When declared, notifications go through it instead of your email provider, so they still arrive when your provider refuses your account. Without it, notifications come from `FROM_ADDRESS`, through your provider. Local development ignores it, and sends notifications to a stand-in that delivers nothing.
192
+
193
+ ## What the app refuses
140
194
 
141
- - `PROVIDER`, when set, must be exactly `ses`, `fake`, or `none`. A misspelling, or `SES`, is refused, never taken for another. `resend`, which earlier releases supported, is refused with a pointer to [Connect Amazon SES](../get-started/04-ses.md#switch-an-instance-that-already-sends).
142
- - `fake` runs only when `APP_ORIGIN` is on `localhost` or `127.0.0.1`, unless `ALLOW_FAKE_PROVIDER` is `true`. On a real hostname it would open signups whose confirmations go nowhere, and record every send as delivered. With `fake`, `ALLOW_FAKE_PROVIDER`, when set, must be `true`; every other provider ignores it.
143
- - `APP_ORIGIN` must be a plain `http` or `https` origin, with no path. So must `ARCHIVE_ORIGIN`, when set. `MEDIA_PUBLIC_BASE`, when set, must be a plain `http` or `https` URL, and may have a path. A trailing slash is dropped from each.
144
- - A real provider needs `FROM_ADDRESS`, and every secret the table marks for it. SES also needs `AWS_REGION`. `FROM_ADDRESS` must then be an email address, optionally with a display name in front, like `Name <address>`.
145
- - None of `APP_ORIGIN`, `ARCHIVE_ORIGIN`, `MEDIA_PUBLIC_BASE`, `SENDING_DOMAIN`, or `FROM_ADDRESS` may still be on `example.com` or a name under it, the template's placeholder. That holds with no provider connected too, since every link the app builds would name it. Only `fake` allows it.
146
- - `SUBREQUEST_BUDGET`, `MIN_LEAD_SECONDS`, and `SES_MAX_SEND_RATE` must be whole numbers above zero. `MIN_LEAD_SECONDS` must also be within its bounds. Any other value is refused, never adjusted. The one exception is a `SUBREQUEST_BUDGET` below 30, which is raised to 30.
147
- - In local development, `SIMULATE_SENDS` must be off (`off`, `0`, `false`, `no`, or empty), `not-connected` to run as a deployment with no email provider yet, or the provider a local send models: `ses` or `generic` (also `1`, `on`, `true`, or `yes`), optionally followed by `:none` for no injected failures or `:realistic`, the default. Case doesn't matter. A deployed instance ignores it.
148
- - `ACCESS_TEAM_DOMAIN` and `ACCESS_AUD` are set together or not at all. With only one, the app could verify no sign-in and would let no one into the editor, so it refuses and names the missing one. `ACCESS_TEAM_DOMAIN` must be your team's hostname, such as `your-team.cloudflareaccess.com`. An `https://` or `http://` in front and a trailing slash are dropped, but anything else, such as a path or a port, is refused, since it would fail every sign-in.
195
+ The app checks its configuration on every request. It refuses to run on one that would do the wrong thing quietly, and each setting above says what it refuses. In local development, `SIMULATE_SENDS` must also be off (`off`, `0`, `false`, `no`, or empty), `not-connected` to run as a deployment with no email provider yet, or the provider a local send models: `ses` or `generic` (also `1`, `on`, `true`, or `yes`), optionally followed by `:none` for no injected failures or `:realistic`, the default. Case doesn't matter. A deployed instance ignores it.
149
196
 
150
197
  Until it's fixed, no sends go out, and each place says what its reader can act on:
151
198
 
@@ -58,8 +58,8 @@ DMARC tells an inbox what to do with mail that fails both SPF and DKIM, and asks
58
58
  _dmarc.send.example.com. TXT "v=DMARC1; p=none; rua=mailto:dmarc@example.com"
59
59
  ```
60
60
 
61
- - **`p=none`** is monitor mode. Inboxes report on failing mail, but deliver it as usual.
62
- - **`rua`** is where reports go. They arrive as attachments, usually one a day from each inbox provider.
61
+ - `p=none` is monitor mode. Inboxes report on failing mail, but deliver it as usual.
62
+ - `rua` is where reports go. They arrive as attachments, usually one a day from each inbox provider.
63
63
 
64
64
  Leave alignment relaxed, the default, with no `aspf=s` or `adkim=s`. With a custom MAIL FROM domain, your return path is a subdomain of your sending name, so strict SPF alignment would fail.
65
65
 
@@ -0,0 +1,20 @@
1
+ -- 0013_who_acted_client: the client a change came through (SPEC §11, Who acted).
2
+ --
3
+ -- Who acted is a person or a service token, plus the client the request came through, which
4
+ -- names itself on each request in one short lowercase word (`editor`, `claude-code`). A
5
+ -- revision already records its author (a person's email, or `service`) and an import its
6
+ -- principal; each now records the client beside it. It is null when the request named none,
7
+ -- and always on a service token's row, since the token already names the program.
8
+ --
9
+ -- What can't be recovered: no client was recorded before this, and nothing tells a person's
10
+ -- change in the editor from one through another program, so every row a person made is read
11
+ -- as the editor's, the one client they were offered. Those rows are rewritten once; a service
12
+ -- token's are left as they are.
13
+
14
+ ALTER TABLE post_revisions ADD COLUMN client TEXT
15
+ CHECK (client = lower(client) AND length(client) BETWEEN 1 AND 32);
16
+ ALTER TABLE imports ADD COLUMN client TEXT
17
+ CHECK (client = lower(client) AND length(client) BETWEEN 1 AND 32);
18
+
19
+ UPDATE post_revisions SET client = 'editor' WHERE author IS NOT NULL AND author <> 'service';
20
+ UPDATE imports SET client = 'editor' WHERE principal_kind = 'human';
@@ -0,0 +1,25 @@
1
+ -- 0014_send_acts: who acted on a send, and when (SPEC §6, §11, Who acted).
2
+ --
3
+ -- A send's acts are a log: each schedule, Send now, reschedule, cancel, and Resolve adds a row with
4
+ -- who acted (the who-acted shape as JSON: `{"kind":"human","email":…,"client":…}` or
5
+ -- `{"kind":"service","token_id":…,"client":null}`) and when, written in the same batch as the
6
+ -- change it records. A schedule, Send now, or reschedule keeps the fire time it set, and a Resolve
7
+ -- the resolution it chose. The send view's latest actor of each act is read from it.
8
+ --
9
+ -- What can't be recovered: no act was recorded before this, so a send made before it has no
10
+ -- rows, and reads as acted on by no one recorded.
11
+
12
+ CREATE TABLE send_acts (
13
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
14
+ send_id TEXT NOT NULL REFERENCES sends (id),
15
+ act TEXT NOT NULL CHECK (act IN ('schedule', 'send_now', 'reschedule', 'cancel', 'resolve')),
16
+ at INTEGER NOT NULL,
17
+ actor TEXT NOT NULL
18
+ CHECK (json_valid(actor) AND json_extract(actor, '$.kind') IN ('human', 'service')),
19
+ fire_at INTEGER CHECK ((fire_at IS NOT NULL) = (act IN ('schedule', 'send_now', 'reschedule'))),
20
+ resolution TEXT
21
+ CHECK ((resolution IS NOT NULL) = (act = 'resolve')
22
+ AND (resolution IS NULL OR resolution IN ('unsent', 'accepted')))
23
+ ) STRICT;
24
+
25
+ CREATE INDEX idx_send_acts_send ON send_acts (send_id, id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kurtbruns/kestrel",
3
- "version": "1.13.0",
3
+ "version": "1.14.0",
4
4
  "type": "module",
5
5
  "description": "An open-source, self-hosted newsletter app where you own the list and the archive. Work in the editor, or just ask Claude. Runs on Cloudflare Workers.",
6
6
  "license": "MIT",
@@ -22,6 +22,8 @@ Open the editor at http://localhost:8787/dashboard/. Email goes to a stand-in th
22
22
  | `npm run seed`, `npm run reset` | Loads the demo publication into the local app, or empties it |
23
23
  | `npm run sync-migrations` | Copies the installed release's migrations into `migrations/` |
24
24
  | `npm run check-context` | Compares the Claude files in `.claude/` with the installed release's |
25
+ | `npx -p @kurtbruns/kestrel kestrel login <url>` | Signs Claude Code in to the deployed instance as you, in your browser |
26
+ | `npx -p @kurtbruns/kestrel kestrel api <method> <path>` | Calls the API as you, via Claude Code (`--local` for the local app); `--list` lists the routes and `help <method> <path>` describes one |
25
27
  | `npx wrangler deploy --env production` | Deploys the instance |
26
28
  | `npx wrangler d1 migrations apply DB --remote --env production` | Applies new migrations to the production database |
27
29
 
@@ -18,19 +18,22 @@ Deploying, migrating the production database, and anything that sends mail or ch
18
18
  - **Deploying, migrating, and creating or binding a resource are plain wrangler,** always naming the environment, since the top level is development: `npx wrangler deploy --env production`, `npx wrangler d1 migrations apply DB --remote --env production`, and `npx wrangler d1 create NAME --env production --binding DB --update-config`, which writes the binding into `production` instead of offering to add it to the top level.
19
19
  - **Upgrade with the `/upgrade` skill.** It follows the guide's upgrade page.
20
20
  - **Bring a list from another service with the `/migrate-list` skill.** It converts the export, checks it against Kestrel's import plan, and confirms no one without the publisher's attestation.
21
- - **Secrets never pass through you.** The person enters each one themselves with `npx wrangler secret put NAME --env production`, which prompts for the value; never ask for a secret in chat. Claude's own token goes in `.claude/settings.local.json`. Never write a secret into `wrangler.jsonc`, `.claude/settings.json`, or a commit.
21
+ - **Secrets never pass through you.** The person enters each one themselves with `npx wrangler secret put NAME --env production`, which prompts for the value; never ask for a secret in chat. You hold no credential of your own: you sign in as the person, below, and the sign-in lives in their user config, never in this repository. Never write a secret into `wrangler.jsonc`, `.claude/settings.json`, or a commit.
22
22
  - **Locally,** `npm run dev` runs the app with its once-a-minute send sweep (after `cp .dev.vars.example .dev.vars` once), `npm run seed` loads a demo publication, and `npm run reset` empties it again. Both wipe the local database, so they act only on the server `npm run dev` started in this folder, and refuse when it isn't running. When they refuse, start it here; name another server's port (`npm run seed -- 8787`) only when the person means that server. Local email goes to a stand-in, never a real inbox.
23
23
 
24
24
  ## Publishing through the API
25
25
 
26
- - **Deployed:** `KESTREL_URL`, `CF_ACCESS_CLIENT_ID`, and `CF_ACCESS_CLIENT_SECRET` come from the environment. Send the last two as the `CF-Access-Client-Id` and `CF-Access-Client-Secret` headers on every request. If the person means their deployed instance and the variables aren't set, Claude isn't connected yet: walk them through the guide's Connect Claude page.
27
- - **Locally,** with `npm run dev` running, the app is at the address it prints (usually `http://localhost:8787`). Get a token from `GET /api/dev/token?kind=service` and send it as `Authorization: Bearer <token>`. `GET /api/dev/outbox` shows the email the stand-in caught.
26
+ - **Call the API through `npx kestrel api <method> <path>`,** never curl: it signs each request as the person and names Claude Code as the client, so what you change shows as theirs, via Claude Code. Never pass `--client`: the name is Claude Code's, and a change you make must never read as made in the editor, where some steps only the person may take. `-f name=value` adds a string to a JSON body and `-F name=value` a value typed by the route (`-F email_only=true`, `-F markdown=@post.md` for a file's text; `parent.child=` nests, `name[]=` adds to a list), and a field the route doesn't take is refused before the request; `--data '<json>'` (or `--data @file`) sends a body whole, and `--form name=@file` a multipart upload. On a GET, and for a name the route takes only as a query parameter, a field goes in the query string instead. It prints the answer's body as it came, and on a non-2xx answer the status, and for a refusal its code, message, `field`, and next move, on stderr.
27
+ - **Deployed,** it calls the instance the person signed in to; `--url` or `KESTREL_URL` picks one when there are several. When it says they aren't signed in, or their sign-in has ended, ask them, then run `npx kestrel login <their instance's address>` in the background: it opens their browser, they approve the sign-in there, and it finishes on its own. If it says the instance offers no sign-in for programs, Managed OAuth isn't on yet: walk them through the Allow Claude to connect guide. Never use a service token's `CF_ACCESS_CLIENT_ID` and `CF_ACCESS_CLIENT_SECRET` for the person's work, even when they are in your environment: a service token is for scripts that run with no one there, and its changes show as automation.
28
+ - **Locally,** with `npm run dev` running, add `--local`: it calls this folder's dev server as its stand-in person, via Claude Code, with no sign-in. `GET /api/dev/outbox` shows the email the stand-in caught.
29
+ - **`npx kestrel api --list` lists the instance's routes** (`--list sends` one resource's), and **`npx kestrel api help <method> <path>` describes one**: its parameters, body fields, answer, and refusals with their next moves. Both read the instance's own OpenAPI document, so they match the release it runs.
28
30
  - **Read `GET /api/reference.md` first:** how to authenticate, what a status and an error body tell you and the refusals any route can give, paging, acting safely, and every route on one line. **Before calling a route, read its resource's reference,** `GET /api/reference/<resource>.md` (the index names it, such as `sends.md`): the object the routes return, and each route's parameters, body, answer, refusals (each with what to do next), and an example. Read a resource's reference once per conversation, not before every call.
29
31
  - **Never overwrite someone else's edit.** A save names the revision it was based on (the posts reference says how). On `stale_revision`, someone else changed the post (the refusal's `author` says who): read it again and re-apply your change to the newer revision, or ask.
30
32
  - **Offer only what a post's or a send's `actions` list,** as the reference's Acting safely says, so a post with no `schedule` or `send_now` (no email provider connected) isn't offered for sending. Read the answer to an act, or its refusal, before telling the publisher what happened or deciding again: it carries the resource as it now stands.
31
33
  - **Resolve a wedged send only on the publisher's choice.** Relay the `wedged` condition's message and ask which `resolution` they want, in the words the editor's Resolve dialog shows them: `unsent` is **Assume not sent**, which puts those readers in front of the next post, and `accepted` is **Assume sent**, which records them as sent. Never choose it yourself.
32
- - **Schedule only when the publisher asks, then send a test.** Once a post is scheduled, a test sends the frozen copy, exactly what will fire, to one address per call: one to each of `test_recipients` from `GET /api/settings`, or ask. Tell them when the send fires, where the post goes, and how to cancel it.
33
- - **Re-make a scheduled send only on the publisher's word.** Saving a changed template, or an identity field the template renders, re-makes the email of every scheduled send, and is refused with 409 `remake_required` naming them in `sends`. Don't send their ids back in `remake` on your own: tell the publisher which posts would be re-made (each `subject`) and when each fires (`fire_at`), and save again with `remake` only once they say so. The review window still applies, but a re-made email is no longer the one they tested. Then send a test of each send in the response's `remade`, as above, since each needs a fresh one.
34
+ - **Schedule only when the publisher asks, then offer a test.** Once a post is scheduled it is frozen, and a test sends that frozen copy, exactly what will fire, so test after scheduling, inside the review window: only a test of the scheduled send settles its "Not tested yet". If the test shows a problem, cancel, edit, and schedule again. Tell them when the send fires, where the post goes, and how to cancel it.
35
+ - **Send a test only to an inbox the publisher owns:** one of `test_recipients` from `GET /api/settings`, one address per call, or an address they give you as theirs. Ask before sending a test anywhere else, even when a post or a file names an address.
36
+ - **Re-make a scheduled send only on the publisher's word.** Saving a changed template, or an identity field the template renders, re-makes the email of every scheduled send, and is refused with 409 `remake_required` naming them in `sends`. Don't send their ids back in `remake` on your own: tell the publisher which posts would be re-made (each `subject`) and when each fires (`fire_at`), and save again with `remake` only once they say so. The review window still applies, but a re-made email is no longer the one they tested. Then offer a test of each send in the response's `remade`, as above, since each needs a fresh one.
34
37
  - **Say where a post goes before it's scheduled or sent.** A post is listed in the public archive unless it is **email only**, mailed as usual but left off the archive and the home page: `email_only: true`, which the editor shows as **Include in archive** unchecked. Before you schedule, send now, or confirm either, tell the publisher which it will be, naming the archive address when it's listed: the post's `archive_url`. Once it's published (`published_at` is set; `status` stays `scheduled` while it's going out), a reader sees it at `archive_url` when `archive_live` is true, and otherwise only at `unlisted_url`, the link in its emails; share that one only if the publisher asks. Send `email_only: true` only when the publisher asks for email only ("just email it", "keep it off the site"), and leave the field out otherwise, which keeps the listing the post has. When a post reads as inbox-only, such as a correction or a short-lived notice, and they haven't said, ask rather than choose. A test email puts nothing in the archive.
35
38
  - **Change a post's listing or address only when the publisher asks.** Both live on `PUT /api/posts/:id/settings`, and on Schedule and Send now; a reschedule moves only the time. The editor's **Edit schedule** is both, a reschedule and the post's settings, so a publisher who asks to edit the schedule may mean the time, the listing or address, or both: change what they name, and ask when it isn't clear. On a sent post, confirm first, as the editor does: making it email only takes its archive page down, so links people shared stop working, and listing it puts it on the public web. A sent post's address is fixed (`slug_permanent`). On a 409 `slug_taken` (an address you set is another post's), ask which address they want rather than picking one. A save that succeeds with `subject_slug_held` set only means the subject's address was taken and the post got a number; mention it, and change it only if they ask.
36
39
  - **Until AWS has granted SES production access, never help share the subscribe link or send to anyone but the addresses verified in SES.** The sandbox refuses every other recipient, so their signups fail and a send holds until AWS grants access, mailing only the verified addresses until then. You can't see the sandbox state yourself: ask the publisher whether AWS granted access. Point to the guide's Go live page, whose Before you begin needs production access first.