Skip to content

Local development

Run Chaski and your app on your machine with wrangler dev, against a local database, with every email kept on disk.

wrangler dev runs everything Chaski uses on your machine: the database, the queue and its consumer, the Durable Objects and the Workflow. Nothing you declare has to exist in your Cloudflare account yet.

Run Chaski

chaskijs init leaves the project ready to run: it applied Chaski’s migrations to the local database and wrote .dev.vars, which only wrangler dev reads, with a local secret and the local environment name:

.dev.vars
CHASKI_SECRET=<32 random bytes, in base64>
CHASKI_ENVIRONMENT=local
CHASKI_PUBLIC_URL=http://localhost:8788/me/notifications

CHASKI_PUBLIC_URL is there when you gave init one. Point it at the local address of your app’s userRoutes() mount, so the pixel of local emails reaches your app. Then start Chaski:

Terminal
npx wrangler dev

The dashboard is at http://localhost:8787. Cloudflare Access doesn’t sit in front of wrangler dev, so Wrangler signs in the identity under access.dev in wrangler.jsonc, as Access would; changes you make are recorded under it. The local dashboard keeps settings of its own: set up the channels there as you would deployed.

The local database follows database_id. When chaskijs deploy writes the id of the database it created, wrangler dev starts on a new local database, which the deploy migrates for you, and the local settings start over. chaskijs upgrade applies a new version’s migrations locally too. In a project you set up by hand, apply them yourself, before the first run and after each upgrade:

Terminal
npx wrangler d1 migrations apply CHASKI_DB --local

Run your app next to it

Start your app with wrangler dev in a second terminal, on another port. A service binding finds a Worker another wrangler dev runs on the same machine by its name, so your app’s CHASKI binding reaches the local Chaski, and Wrangler marks it [connected] once both run:

Terminal
npx wrangler dev --port 8788

Sends from your app, and the user routes it mounts, then work as they do deployed, the feed’s WebSocket included. The CHASKI_PUBLIC_URL above points the pixel of local emails at this port.

Seed the templates

A template file you keep in your repository loads into the local database with the command chaskijs ships and Wrangler’s own d1 execute. The command turns the file into SQL that applies it the way templates().import() does: templates match by type, fields the file leaves out are cleared, channels it leaves out keep their content, and a template that already matches isn’t written, so running it again changes nothing. In the Chaski project:

package.json
{
  "scripts": {
    "seed": "wrangler d1 migrations apply CHASKI_DB --local && chaskijs templates sql chaski-templates.json > .wrangler/chaski-templates.sql && wrangler d1 execute CHASKI_DB --local --file .wrangler/chaski-templates.sql"
  }
}

The SQL follows the tables of the chaskijs version you installed, so apply its migrations first, as the script does. With --remote in place of --local, the same two steps load the file into the deployed database from a deploy job; add --env staging for another environment. Your app can also load the file over the binding with env.CHASKI.templates().import(file). A dashboard already open shows the change once you reload it.

Schedules across restarts

wrangler dev keeps your schedules when it restarts, but its local Workflows engine doesn’t wake an instance that was asleep when the server stopped: a schedule made before the restart stays pending past its time, and Schedules in the dashboard marks it overdue. Schedules made after the restart fire on time, and deployed Workflows don’t have this limitation. To wake one, call schedule() again with the same idempotencyKey, which restarts its instance, or restart the instance from the local explorer or with npx wrangler workflows instances restart <workflow name> <schedule id> --local. The instance id is the schedule’s: scheduledId from schedule(), id from schedules().list().

Read the emails

wrangler dev stands in for Email Service: each email Chaski sends lands on disk instead of in an inbox, so nothing reaches a real address. The log prints where its HTML, its text and each attachment went, under .wrangler/tmp/email/, and the local explorer lists every one with its sender, recipients, reply-to address and attachments:

Terminal
curl http://localhost:8787/cdn-cgi/local/explorer/api/local/email/sending

Web push is different: it goes to the push service of each browser subscribed against the local database, which is only ever a browser you turned push on in yourself.

Try a send

With a template for the type in place, send from a route of your app, open the local dashboard and follow the delivery from Deliveries. Templates shows how to create the first one.

Test your app

Your app’s tests in workerd, with @cloudflare/vitest-plugin, run without a Chaski Worker: stand in for the binding with an object that records what your code asks of it.

test/billing.test.ts
import { expect, it, vi } from 'vitest';
import { sendReceipt } from '../src/billing';
import type { Env } from '../src/env';

it('sends the receipt once per payment', async () => {
  const send = vi.fn();
  const env = { CHASKI: { send } } as unknown as Env;

  await sendReceipt(env, { id: 'user_1', email: 'ana@example.com' }, { id: 'pay_1', amount: '$15.00' });

  expect(send).toHaveBeenCalledWith(
    expect.objectContaining({ type: 'payment_received', idempotencyKey: 'payment:pay_1' }),
  );
});

What a send does from there, templates, preferences and delivery, is Chaski’s to test, and its own suite covers it.

Updated Oct 30, 2026

MIT License · chaski.sh