@mediamonks/canopy-cli 0.7.19 → 0.7.21

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 (36) hide show
  1. package/dist/{dev-DtEwiNYi.mjs → dev-CNqx_53I.mjs} +3 -3
  2. package/dist/dev-D7v7WK9O.mjs +2 -0
  3. package/dist/{dev-worker-IWnHvQ1f.mjs → dev-worker-Bij9OUMk.mjs} +1 -1
  4. package/dist/docs/cli-dev.md +1 -1
  5. package/dist/docs/cli-logs.md +1 -1
  6. package/dist/docs/data.md +2 -2
  7. package/dist/docs/dev.md +1 -1
  8. package/dist/docs/developers-architecture.md +26 -18
  9. package/dist/docs/index.json +8 -8
  10. package/dist/docs/migrations.md +17 -14
  11. package/dist/docs/notifications.md +14 -1
  12. package/dist/docs/platform-egress.md +2 -2
  13. package/dist/docs/platform-project-data.md +3 -3
  14. package/dist/docs/platform-schedules.md +1 -1
  15. package/dist/docs/platform-sites-and-profiles.md +1 -1
  16. package/dist/docs/sdk-email.md +114 -0
  17. package/dist/docs/sdk-files.md +11 -1
  18. package/dist/docs/sdk.md +4 -4
  19. package/dist/docs/static-serving.md +58 -300
  20. package/dist/{docs-CKZV1X-B.mjs → docs-fiC6q9Ce.mjs} +1 -1
  21. package/dist/{feedback-DBgUpmDC.mjs → feedback-CAwUHwWa.mjs} +1 -1
  22. package/dist/index.mjs +9 -9
  23. package/dist/{local-CKRZur_5.mjs → local-CsSP4Vpt.mjs} +2 -2
  24. package/dist/login-DRYaJcfT.mjs +285 -0
  25. package/dist/{package-qe7a3VEV.mjs → package-C_DLlB96.mjs} +1 -1
  26. package/dist/{skill-DW68KWTY.mjs → skill-CmgMU2Iq.mjs} +1 -1
  27. package/dist/{src-Bu4Ze32I.mjs → src-BKKAEkbH.mjs} +25 -2
  28. package/dist/templates/spa/package.json +1 -1
  29. package/dist/templates/worker/AGENTS.md +3 -0
  30. package/dist/templates/worker/package.json +1 -1
  31. package/dist/templates/worker/worker/index.ts +20 -3
  32. package/dist/{upgrade-DNOIy4g3.mjs → upgrade-D1j0WV4l.mjs} +1 -1
  33. package/package.json +5 -4
  34. package/dist/dev-DjkK-50R.mjs +0 -2
  35. package/dist/docs/infrastructure.md +0 -406
  36. package/dist/login-D9eO1s7W.mjs +0 -401
@@ -37,7 +37,7 @@ async function workerPlugin(flags, slug) {
37
37
  console.log(`note: canopy.json points "worker" at ${entry}, which does not exist — skipping.`);
38
38
  return;
39
39
  }
40
- const { missingSecretNames, readDevVars, workerDevPlugin } = await import("./dev-worker-IWnHvQ1f.mjs");
40
+ const { missingSecretNames, readDevVars, workerDevPlugin } = await import("./dev-worker-Bij9OUMk.mjs");
41
41
  const secrets = readDevVars(flags.dir);
42
42
  const email = siteConfig().email;
43
43
  const plugin = workerDevPlugin({
@@ -165,7 +165,7 @@ async function devRemote(flags, slug) {
165
165
  async function devLocal(flags, slug) {
166
166
  let dump;
167
167
  if (flags.pull) {
168
- const { fetchProjectDump } = await import("./local-CKRZur_5.mjs");
168
+ const { fetchProjectDump } = await import("./local-CsSP4Vpt.mjs");
169
169
  console.log(`Pulling a fresh export of ${slug}${flags.staging ? " [staging]" : ""}…`);
170
170
  dump = await fetchProjectDump(slug, flags.staging ? "staging" : "live");
171
171
  }
@@ -180,7 +180,7 @@ async function devLocal(flags, slug) {
180
180
  const reason = err instanceof ApiError ? err.explain().why : String(err);
181
181
  console.log(`Note: ai/services will be unavailable — could not reach the platform (${reason}).`);
182
182
  }
183
- const { createSandbox } = await import("./local-CKRZur_5.mjs");
183
+ const { createSandbox } = await import("./local-CsSP4Vpt.mjs");
184
184
  const sandbox = await createSandbox({
185
185
  slug,
186
186
  persistDir: join(flags.dir, ".canopy", "local"),
@@ -0,0 +1,2 @@
1
+ import { t as devCommand } from "./dev-CNqx_53I.mjs";
2
+ export { devCommand };
@@ -1,4 +1,4 @@
1
- import "./src-Bu4Ze32I.mjs";
1
+ import "./src-BKKAEkbH.mjs";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { relative, resolve, sep } from "node:path";
4
4
  //#region ../data-plane/src/worker-request.ts
@@ -29,7 +29,7 @@ canopy dev --local --pull # sandbox hydrated from a fresh prod export
29
29
 
30
30
  **Local sandbox (`--local`).** Database, file, and auth requests are answered entirely on your machine, from a sandbox stored under `.canopy/local/` in the project directory — no database to install, and writes never reach the platform. The sandbox behaves the same as the real API, so code that works locally works deployed. Two things still go remote: `ai` and `services` calls go through to the real platform (metered as usual) when you're logged in — fully offline, they return errors until you log in and restart.
31
31
 
32
- **Matching your site's SQL dialect.** The document API (`canopy.db.collection(…)`) behaves identically everywhere, but raw `canopy.db.sql()` speaks the deployment target's dialect — SQLite on Cloudflare-backed sites, Postgres on GCP-backed ones (see [dialect()](https://docs.canopy.website/guides/data)). The sandbox defaults to Postgres. If your project uses raw SQL written for a SQLite site, lock the dialect in `canopy.json`:
32
+ **Matching your site's SQL dialect.** The document API (`canopy.db.collection(…)`) behaves identically everywhere, but raw `canopy.db.sql()` speaks your site's dialect — SQLite or Postgres (see [dialect()](https://docs.canopy.website/guides/data)). The sandbox defaults to Postgres. If your project uses raw SQL written for a SQLite site, lock the dialect in `canopy.json`:
33
33
 
34
34
  ```json
35
35
  { "slug": "my-project", "dialect": "sqlite" }
@@ -5,7 +5,7 @@ description: Read your project worker's logs (console output) from the terminal
5
5
 
6
6
  `canopy logs` prints what your project's worker wrote to `console.log` / `console.error`. Reach for it when the worker misbehaves — a 500, an unexpected 403, a schedule that "did nothing" — instead of adding diagnostic endpoints and redeploying. The same output lives in the dashboard under the project's **Logs** tab.
7
7
 
8
- Logs are available on every site that runs workers. On a site where the platform can't read worker output, the command says so honestly instead of guessing. One caveat on Cloudflare-hosted sites: log capture starts with a project's next deploy after 2026-08-19 — a worker that hasn't been redeployed since then has nothing to show yet.
8
+ Logs are available on every site that runs backends. On a site where the platform can't read your backend's output, the command says so honestly instead of guessing. One caveat: on some sites log capture starts with a project's next deploy after 2026-08-19, so a backend that hasn't been redeployed since then has nothing to show yet.
9
9
 
10
10
  ## Usage
11
11
 
package/dist/docs/data.md CHANGED
@@ -3,8 +3,8 @@ title: Working with project data
3
3
  description: Pulling, snapshotting, and restoring your project database.
4
4
  ---
5
5
 
6
- Each Canopy project gets its own database and blob storage (D1 and R2 on the
7
- Cloudflare target; Postgres and GCS on GCP). Your app reads and writes them
6
+ Each Canopy project gets its own database and its own file storage. Your app
7
+ reads and writes them
8
8
  through the Canopy SDK at runtime; the CLI gives you tools to manage the data
9
9
  around it.
10
10
 
package/dist/docs/dev.md CHANGED
@@ -39,7 +39,7 @@ canopy dev --local --pull
39
39
  By default the sandbox runs PGlite (embedded Postgres), so raw
40
40
  `canopy.db.sql()` speaks the postgres dialect there, while your deployed
41
41
  project speaks whatever its platform target speaks (`canopy.db.dialect()`
42
- tells you — sqlite on Cloudflare). Collections (`canopy.db.collection(...)`)
42
+ tells you). Collections (`canopy.db.collection(...)`)
43
43
  behave the same in both, so this only matters for hand-written SQL — see
44
44
  `canopy docs data`. If your project's raw SQL targets a sqlite site, lock the
45
45
  dialect in `canopy.json` (`"dialect": "sqlite"`) and the sandbox runs an
@@ -42,8 +42,8 @@ fall back to `index.html`, so client-side routes like `/settings` work on a
42
42
  hard refresh without any configuration. Rollback just repoints "live" at an
43
43
  earlier version — instant, nothing rebuilt.
44
44
 
45
- The full path — including caching, version-pinned `v12-dot-<slug>` preview
46
- hosts, and the security review — is in
45
+ The full picture — caching rules, deploy limits, and version-pinned
46
+ `v12-dot-<slug>` preview hosts — is in
47
47
  [How static files are served](https://docs.canopy.website/guides/static-serving).
48
48
 
49
49
  ## The optional backend worker
@@ -63,44 +63,52 @@ a backend, declared in `canopy.json`:
63
63
  ```
64
64
 
65
65
  Requests under `/api/*` reach the worker (Hono-style handlers); everything
66
- else is still static serving. Three things to know:
66
+ else is still static serving. A few things to know:
67
67
 
68
68
  - **Requests arrive authenticated.** The worker sits behind the same gateway
69
69
  wall as the frontend.
70
- - **Platform access goes through the same SDK**, via the `CANOPY` service
71
- binding — same budgets, same data isolation as the browser:
70
+ - **The caller is already identified.** `c.env.USER` is the signed-in person
71
+ (`null` during a scheduled run) — no round-trip needed:
72
72
 
73
73
  ```ts
74
- app.get("/api/hello", async (c) => {
75
- const canopy = createCanopy({ fetch: c.env.CANOPY.fetch.bind(c.env.CANOPY) });
76
- const user = await canopy.auth.user();
74
+ app.get("/api/hello", (c) => {
75
+ const user = c.env.USER;
76
+ if (!user) return c.json({ error: "unauthenticated" }, 401);
77
77
  return c.json({ message: `Hello, ${user.name}!` });
78
78
  });
79
79
  ```
80
80
 
81
+ - **Platform access goes through the same SDK**, via the `CANOPY` service
82
+ binding — same budgets, same data isolation as the browser:
83
+
84
+ ```ts
85
+ const canopy = createCanopy({ fetch: c.env.CANOPY.fetch.bind(c.env.CANOPY) });
86
+ const notes = await canopy.db.collection("notes").find();
87
+ ```
88
+
81
89
  - **Secrets and schedules come from the dashboard.** Secrets (Settings →
82
90
  Secrets) land as Workers-shaped bindings (`c.env.MY_API_KEY`, not
83
91
  `process.env`); dashboard Schedules invoke an exported
84
92
  `scheduled(event, env)` — switch on `event.name`.
85
93
 
86
- > **Warning:** Backend workers are capability-gated per target: today the GCP target runs them, while on
87
- > Cloudflare sites a worker deploy answers a clear "not supported yet". Frontend-only projects
88
- > deploy everywhere.
94
+ > **Note:** Backends run on the `main` and `gcp-pilot` sites. On a site without backend support, a deploy that
95
+ > includes one answers with a clear "not supported here" rather than failing obscurely;
96
+ > frontend-only projects deploy everywhere.
89
97
 
90
98
  ## Where your data lives
91
99
 
92
100
  Every project gets its own database and its own blob-storage prefix. No other
93
101
  project can read them, and deleting the project tears everything down. The
94
- document API is portable across targets; raw SQL speaks the target's dialect
102
+ document API works the same on every site; raw SQL speaks your site's dialect
95
103
  (`sqlite` or `postgres` — ask `canopy.db.dialect()`). Dumps, snapshots, and
96
104
  restores are CLI commands — see the [project data guide](https://docs.canopy.website/guides/data).
97
105
 
98
106
  ## Sites
99
107
 
100
108
  A _site_ is one installation of the whole platform. The CLI ships three
101
- profiles — `main` (canopy.website, Cloudflare), `staging` (thanarie.dev,
102
- Cloudflare), and `gcp-pilot` (canopy.monks.zone, GCP) — and always shows which
103
- one you're on. Projects pin theirs in `canopy.json`; `--profile` or
104
- `CANOPY_PROFILE` overrides per command. Most projects only ever use `main`.
105
- Target differences (dialect, full-text search, worker support) surface as
106
- capabilities, not separate APIs.
109
+ profiles — `main`, `staging`, and `gcp-pilot` — and always shows which one
110
+ you're on. Projects pin theirs in `canopy.json`; `--profile` or
111
+ `CANOPY_PROFILE` overrides per command. Most projects only ever use `main`;
112
+ see [sites and profiles](https://docs.canopy.website/platform/sites-and-profiles) for what the other two
113
+ are. Where sites differ (SQL dialect, full-text search, backend support) it
114
+ surfaces as a capability, not a separate API.
@@ -80,17 +80,12 @@
80
80
  {
81
81
  "slug": "notifications",
82
82
  "title": "Slack notifications",
83
- "summary": "Canopy DMs you about your reports and projects — replies, new reports, blocked fetches, budget hits, failing schedules."
83
+ "summary": "Canopy DMs you about your reports and projects — replies, new reports, blocked fetches, budget hits, failing schedules — and answers questions when you ask."
84
84
  },
85
85
  {
86
86
  "slug": "static-serving",
87
87
  "title": "How static files are served",
88
- "summary": "The full path a browser request takes to a deployed file."
89
- },
90
- {
91
- "slug": "infrastructure",
92
- "title": "Infrastructure as code",
93
- "summary": "Terraform for the account-level Cloudflare pieces Canopy needs."
88
+ "summary": "Why every page is a file, and what that means for routing, caching, and deploys."
94
89
  },
95
90
  {
96
91
  "slug": "troubleshooting",
@@ -296,7 +291,7 @@
296
291
  {
297
292
  "slug": "sdk",
298
293
  "title": "Using the SDK",
299
- "summary": "One typed client for auth, data, files, AI, and services — no keys, no connection strings, the platform authenticates for you."
294
+ "summary": "One typed client for auth, data, files, AI, email, and services — no keys, no connection strings, the platform authenticates for you."
300
295
  },
301
296
  {
302
297
  "slug": "sdk-auth",
@@ -318,6 +313,11 @@
318
313
  "title": "AI",
319
314
  "summary": "Metered LLM access through the project's budget-capped gateway key — chat, streaming, and an OpenAI-compatible endpoint."
320
315
  },
316
+ {
317
+ "slug": "sdk-email",
318
+ "title": "Email",
319
+ "summary": "Send email from your project without a mail server or an API key — Monks recipients only, with a per-project daily quota and a sent log."
320
+ },
321
321
  {
322
322
  "slug": "sdk-google",
323
323
  "title": "Google",
@@ -65,9 +65,9 @@ data).
65
65
 
66
66
  Pin your dialect first — a Drizzle schema is written for one SQL flavor. Check
67
67
  what your project's site runs on (`await canopy.db.dialect()` or the
68
- [data guide](https://docs.canopy.website/guides/data)) and set `"dialect"` in `canopy.json`: `"sqlite"`
69
- on Cloudflare-backed sites, `"postgres"` on GCP-backed ones. That same field
70
- makes `canopy dev --local` run a matching local engine.
68
+ [data guide](https://docs.canopy.website/guides/data)) and set `"dialect"` in `canopy.json` to match —
69
+ `"sqlite"` or `"postgres"`. That same field makes `canopy dev --local` run a
70
+ matching local engine.
71
71
 
72
72
  Install the tools and describe your schema:
73
73
 
@@ -113,24 +113,30 @@ This is fully local — it diffs your schema files and writes `.sql` into
113
113
  Canopy never hands out connection strings — Drizzle talks to your data through
114
114
  the handles Canopy already provides.
115
115
 
116
- **In a project worker on a Cloudflare-backed site**, `env.DB` is a real D1
117
- handle:
116
+ Both handles are query functions, so both fit Drizzle's **proxy driver** —
117
+ `drizzle-orm/sqlite-proxy` on a sqlite site, `drizzle-orm/pg-proxy` on a
118
+ postgres one. Match it to the `"dialect"` you pinned in `canopy.json`.
119
+
120
+ **In your backend**, `env.DB` takes the statement and its parameters:
118
121
 
119
122
  ```ts title="worker/index.ts"
120
- import { drizzle } from "drizzle-orm/d1";
123
+ import { drizzle } from "drizzle-orm/sqlite-proxy";
121
124
  import { users } from "../src/schema.ts";
122
125
 
123
126
  export default {
124
127
  async fetch(request, env) {
125
- const db = drizzle(env.DB);
126
- const all = await db.select().from(users);
127
- return Response.json(all);
128
+ const db = drizzle(async (sql, params, method) => {
129
+ const { rows } = await env.DB.query(sql, params);
130
+ const values = rows.map((row) => Object.values(row));
131
+ return { rows: method === "get" ? (values[0] ?? []) : values };
132
+ });
133
+ return Response.json(await db.select().from(users));
128
134
  },
129
135
  };
130
136
  ```
131
137
 
132
- **In the browser (or anywhere the SDK runs)**, wrap `canopy.db.sql()` with
133
- Drizzle's proxy driver:
138
+ **In the browser (or anywhere the SDK runs)**, wrap `canopy.db.sql()` the same
139
+ way:
134
140
 
135
141
  ```ts title="src/db.ts"
136
142
  import { drizzle } from "drizzle-orm/sqlite-proxy";
@@ -143,9 +149,6 @@ export const db = drizzle(async (sql, params, method) => {
143
149
  });
144
150
  ```
145
151
 
146
- (On a postgres site use `drizzle-orm/pg-proxy` the same way; in a GCP project
147
- worker, `env.DB.query(text, params)` fits `pg-proxy` directly.)
148
-
149
152
  ## Rehearsing on staging
150
153
 
151
154
  The safest way to try a migration is on the project's staging channel — a
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: Slack notifications
3
- description: Canopy DMs you about your reports and projects — replies, new reports, blocked fetches, budget hits, failing schedules.
3
+ description: Canopy DMs you about your reports and projects — replies, new reports, blocked fetches, budget hits, failing schedules — and answers questions when you ask.
4
4
  ---
5
5
 
6
6
  Canopy sends you Slack DMs when something on the platform needs your
@@ -38,6 +38,19 @@ the categories on or off. To silence one noisy project entirely, use **Mute
38
38
  project** on its Settings tab — personal to you, and your own reports
39
39
  elsewhere keep notifying.
40
40
 
41
+ ## Asking Canopy things
42
+
43
+ The same bot answers questions. DM **Canopy** in Slack — or mention
44
+ **@Canopy** in a channel — and ask in plain language: "what projects do I
45
+ have?", "why is my fetch to that API failing?", "how do I roll back a bad
46
+ deploy?". It looks at your projects (only the ones you can already see) and
47
+ at these docs, and answers with links to the right guide.
48
+
49
+ It can only read. It never deploys, changes settings, or touches your data —
50
+ for that it points you at the dashboard or the CLI. In a channel that's
51
+ linked to a project, it already knows which project you mean; replying in a
52
+ report's notification thread, it knows which report.
53
+
41
54
  ## If DMs don't arrive
42
55
 
43
56
  The bot finds you by your work email. If your Slack account uses a different
@@ -13,8 +13,8 @@ That default is deliberate: a thousand idle vibecoded projects should not be
13
13
  able to call arbitrary internet hosts. When your project genuinely needs an
14
14
  external API, you allow that host yourself — no platform-team ticket.
15
15
 
16
- > **Note:** Enforcement applies on every target. On GCP the runtime answers the 403 itself; on Cloudflare the
17
- > platform vets each outbound request outside your worker. Same rule, same error, one allowlist.
16
+ > **Note:** Enforcement happens outside your code, on every site — so the rule holds even if your project
17
+ > tries to route around it. Same rule, same error, one allowlist.
18
18
 
19
19
  ## Allowing a host
20
20
 
@@ -69,8 +69,8 @@ then run `canopy dev --local --pull` for a throwaway sandbox seeded with it.
69
69
 
70
70
  The SDK's document API (`canopy.db.collection(...)`) behaves the same
71
71
  everywhere Canopy runs. Raw SQL via `canopy.db.sql()` passes straight through
72
- to the underlying database — SQLite on the Cloudflare sites, Postgres on the
73
- GCP pilot — so anything that database supports works. Your code can ask
74
- `canopy.db.dialect()` which one it's talking to.
72
+ to the underlying database, so anything that database supports works. Which
73
+ one your site runs — SQLite or Postgres — your code can ask with
74
+ `canopy.db.dialect()`.
75
75
 
76
76
  - [Working with project data](https://docs.canopy.website/guides/data) — Collection naming rules, dialects and raw SQL, dumps, and restore workflows in depth.
@@ -100,4 +100,4 @@ stable contract between the dashboard and your handler.
100
100
  - **Failures are visible.** A run that throws records its error on the
101
101
  schedule row in Settings, so silent breakage doesn't stay silent.
102
102
 
103
- - [Infrastructure](https://docs.canopy.website/guides/infrastructure) — What runs where — assets, backends, databases — across the Canopy sites.
103
+ - [How the platform serves your project](https://docs.canopy.website/developers/architecture) — Where your backend runs, and what it can reach — the same environment a scheduled run gets.
@@ -15,7 +15,7 @@ the CLI targets them.
15
15
  | ----------- | --------------------- | ------------------------------------------------- |
16
16
  | `main` | `*.canopy.website` | The real platform. Your projects live here. |
17
17
  | `staging` | `*.thanarie.dev` | The platform team's testbed for platform changes. |
18
- | `gcp-pilot` | `*.canopy.monks.zone` | A pilot running on Google Cloud. |
18
+ | `gcp-pilot` | `*.canopy.monks.zone` | A pilot installation, used for evaluation work. |
19
19
 
20
20
  Sites are fully separate: a project on `main` doesn't exist on `staging`, and
21
21
  signing in to one doesn't create anything on another. They can also differ in
@@ -0,0 +1,114 @@
1
+ ---
2
+ title: Email
3
+ description: Send email from your project without a mail server or an API key — Monks recipients only, with a per-project daily quota and a sent log.
4
+ ---
5
+
6
+ `canopy.email` sends transactional email — a digest, a notification, a
7
+ "your request was approved" — from your project code. There is no mail
8
+ server to run, no provider account to open, no API key in your project, and
9
+ no DNS to configure. The platform sends on your behalf and keeps a log of
10
+ what went out.
11
+
12
+ Two rules are enforced by the platform, not by your code:
13
+
14
+ - **Monks recipients only.** Every address must be an `@monks.com` one.
15
+ Anything else is refused before the message is sent.
16
+ - **A daily quota per project**, 100 emails by default. Each recipient
17
+ counts as one. Ask an admin if your project genuinely needs more.
18
+
19
+ ## email.send(options)
20
+
21
+ Resolves once the platform has accepted the message:
22
+
23
+ ```ts
24
+ await canopy.email.send({
25
+ to: "someone@monks.com",
26
+ subject: "Weekly digest",
27
+ text: "Three new entries this week.",
28
+ });
29
+ ```
30
+
31
+ **`EmailSendOptions`:**
32
+
33
+ - `to` — required. One address, or an array of up to 10.
34
+ - `subject` — required, up to 200 characters.
35
+ - `text` — required. The plain-text body. Always sent, even alongside
36
+ `html`: some clients show only this, and it helps you stay out of spam
37
+ folders.
38
+ - `html` — optional HTML body. `text` and `html` together are capped at
39
+ 256 KB.
40
+ - `replyTo` — optional. Follows the same recipient rule as `to`, so replies
41
+ cannot be routed off to an outside address.
42
+
43
+ **Returns** `{ delivered, queued, bounced }` — three arrays of addresses.
44
+ `delivered` is accepted for delivery, `queued` will be retried, and
45
+ `bounced` will never arrive (a wrong address, usually).
46
+
47
+ Attachments are not supported. Link to something in your app instead.
48
+
49
+ ## Who the email comes from
50
+
51
+ Your project sends as itself: the From address is your project's slug at the
52
+ platform's mail domain, with your project's name attached — so a recipient
53
+ can tell at a glance which app is writing to them, and replies do not land
54
+ in a shared inbox.
55
+
56
+ Set `replyTo` when you want answers to reach a real person or team.
57
+
58
+ ## Sending from the backend
59
+
60
+ The most useful sends have nobody signed in — a nightly digest, a
61
+ [scheduled](https://docs.canopy.website/platform/schedules) summary. Email works there too: call it from
62
+ a worker route or a scheduled handler and it authenticates as the project
63
+ itself.
64
+
65
+ ```ts
66
+ export default {
67
+ async scheduled(event, env) {
68
+ const canopy = createCanopy({ fetch: env.CANOPY.fetch.bind(env.CANOPY) });
69
+ const entries = await recentEntries(env.DB);
70
+
71
+ await canopy.email.send({
72
+ to: "team@monks.com",
73
+ subject: `Digest · ${entries.length} new entries`,
74
+ text: entries.map((e) => `· ${e.title}`).join("\n"),
75
+ });
76
+ },
77
+ };
78
+ ```
79
+
80
+ ## Seeing what was sent
81
+
82
+ Your project's **Usage** tab in the dashboard shows how much of today's
83
+ quota you have used and the recent sends — when, to whom, the subject, and
84
+ whether it went out. Message bodies are never stored.
85
+
86
+ ## When it fails
87
+
88
+ Every failure is a `CanopyError` with a status you can branch on:
89
+
90
+ - **400** — the recipient isn't a Monks address, or the request broke one of
91
+ the limits above (too many recipients, subject too long, missing `text`).
92
+ - **429** — the project used its daily quota. It resets at UTC midnight.
93
+ - **501** — email isn't set up on this Canopy site.
94
+ - **502** — the platform accepted the request but the send failed downstream.
95
+
96
+ ```ts
97
+ import { CanopyError } from "@mediamonks/canopy";
98
+
99
+ try {
100
+ await canopy.email.send({ to, subject, text });
101
+ } catch (err) {
102
+ if (err instanceof CanopyError && err.status === 429) {
103
+ // Out of quota for today — queue it, or tell the user.
104
+ }
105
+ }
106
+ ```
107
+
108
+ ## What this is not
109
+
110
+ This is transactional email for a small internal audience. It is not a
111
+ newsletter platform: there are no mailing lists, no subscriber management,
112
+ and no unsubscribe handling — if you send something people can opt into,
113
+ you own that list and that opt-out inside your own app. A project that
114
+ outgrows "email some Monks" should graduate to a dedicated email provider.
@@ -37,8 +37,9 @@ Every upload (and every `list` entry) is described by a `BlobObject`:
37
37
  const blob = await canopy.blob.get("notes.txt");
38
38
  const text = await blob?.text();
39
39
 
40
- // getUrl(key) → Promise<{ url: string; expiresAt: string }>
40
+ // getUrl(key, options?) → Promise<{ url: string; expiresAt: string }>
41
41
  const { url, expiresAt } = await canopy.blob.getUrl("avatars/arjan.png");
42
+ const long = await canopy.blob.getUrl("packs/brand.zip", { expiresIn: 3600 });
42
43
  ```
43
44
 
44
45
  `get` returns the content as a standard `Blob`, typed with the MIME type it
@@ -52,6 +53,15 @@ links, media elements — and prefer `get` when your code needs the content.
52
53
  Request a fresh URL when you render rather than storing one: it expires at
53
54
  `expiresAt`.
54
55
 
56
+ A URL lasts **10 minutes** by default. Pass `expiresIn` (seconds) when a link
57
+ has to outlive that — a download a person starts later, or a sync tool that
58
+ works through a batch of files without coming back for a new link each time.
59
+ The platform caps a lifetime at **24 hours**; ask for more and you get 24
60
+ hours rather than an error, so trust `expiresAt` over what you asked for. A
61
+ lifetime that isn't a whole number of seconds above zero is rejected. Keep the
62
+ window as short as the job needs — anyone holding the URL can download the
63
+ file until it expires.
64
+
55
65
  ## Listing and deleting
56
66
 
57
67
  ```ts
package/dist/docs/sdk.md CHANGED
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  title: Using the SDK
3
- description: One typed client for auth, data, files, AI, and services — no keys, no connection strings, the platform authenticates for you.
3
+ description: One typed client for auth, data, files, AI, email, and services — no keys, no connection strings, the platform authenticates for you.
4
4
  ---
5
5
 
6
6
  `@mediamonks/canopy` is a thin, typed client over the platform's project API.
7
- One `createCanopy()` call gives you six modules — `auth`, `db`, `blob`, `ai`,
8
- `google`, and `services` — and that is the entire surface. There are no API keys,
7
+ One `createCanopy()` call gives you seven modules — `auth`, `db`, `blob`, `ai`,
8
+ `email`, `google`, and `services` — and that is the entire surface. There are no API keys,
9
9
  connection strings, or environment variables to manage: every call rides the
10
10
  session the platform already established before your code ran.
11
11
 
@@ -35,7 +35,7 @@ import { createCanopy } from "/_api/data/v1/sdk.js";
35
35
  import { createCanopy } from "@mediamonks/canopy";
36
36
 
37
37
  const canopy = createCanopy();
38
- // canopy.auth, canopy.db, canopy.blob, canopy.ai, canopy.google, canopy.services
38
+ // canopy.auth, canopy.db, canopy.blob, canopy.ai, canopy.email, canopy.google, canopy.services
39
39
  ```
40
40
 
41
41
  | Option | Type | Default | What it does |