Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,15 @@ jobs:
- name: Build web
run: pnpm --filter @flow/web build

- name: Build the static demo
run: pnpm --filter @flow/web build:demo

# The committed root wrangler.jsonc is what the Deploy to Cloudflare
# button deploys; checked before the step below creates
# apps/api/wrangler.jsonc, so the one-click path is what runs.
- name: One-click config dry-run deploy
run: node scripts/deploy-worker.mjs --dry-run

- name: Wrangler dry-run deploy
working-directory: apps/api
run: |
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ node_modules/

# Build output
dist/
dist-demo/
coverage/

# Cloudflare / wrangler
Expand Down
52 changes: 52 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Changelog

## 0.2.0 (unreleased)

The first tagged release. It covers everything since the initial publish in July 2026. Migrations `core-0009` through `core-0014` run automatically the first time the Durable Object starts on the new code, and every new column is nullable or defaulted, so existing workspaces need no manual steps.

### Try it

- **Deploy to Cloudflare button.** The committed root `wrangler.jsonc` deploys Flow to a `*.workers.dev` address with its Durable Object, R2 bucket and queues. Until Cloudflare Access is set up, the app shows the remaining steps instead of failing, and the public `GET /api/setup` reports whether sign-in is configured.
- **Read-only demo build.** `pnpm build:demo` builds the SPA as a static site on sample data that needs no server, and `pnpm deploy:demo` hosts it as an assets-only Worker. `?embed` hides the demo badge.

### Added

- **Agent handoffs** (`core-0013`). One person's agent files a request for a teammate's agent as a task with a state (requested → working / needs_input → done / declined). It's available over MCP (`flow_request_handoff`, `flow_list_handoffs`, `flow_reply_handoff`) and REST, and the task panel shows a handoff strip. Comments now record `via`, so agent-written replies are labelled.
- **Email intake** (`core-0012`). Each list can take tickets at `<alias>@EMAIL_INTAKE_DOMAIN` from allowlisted senders:
- replies in the thread become comments
- sender authenticity comes from Cloudflare's own authentication results
- authenticated senders can get an optional acknowledgement
- rate limits apply per sender and per intake
- it's managed in Settings → Inbound
- **Email replies** (`core-0014`). A comment posted with `emailToRequester` is emailed to the sender who opened the ticket, in their thread, and the task records "Emailed to …". It's API-only, for keys an owner or admin allowed (`PATCH /api/api-keys/:id`), and never available over MCP.
- **`set_due_date` automation action**, with a time-of-day cutoff in a given time zone and an optional business-days rule.
- **Subtask notes** (`core-0009`): a plain-text note on each subtask.
- **My Work** now includes your own subtasks, plus an "assigned out" rail for work you delegated. It's backed by assignment attribution (`core-0010`).
- **Clickable URL titles.** A task whose title is a link gets an open-link icon.
- **Backups**:
- outcomes are recorded (`core-0011`)
- `/api/health` reports staleness
- `GET`/`POST /api/admin/backup` shows status and runs a backup on demand

### Fixed

- **Daily backups never succeeded:** the runtime's internal `_cf_KV` table can't be read, and the backup job tried to export it.
- **Security:** `GET /ws` now checks the request `Origin` before accepting an Access-cookie handshake.
- **Security:**
- attachments are served as downloads unless they're safe raster images
- cookie-authenticated mutations must be same-origin
- **Security:** Message-ID and reply-subject parsing no longer slow down on crafted input (both were quadratic).
- An empty subtask `PATCH` no longer unchecks a completed subtask.
- The web store no longer inserts half-empty rows for update deltas about unknown ids.
- Moving work across spaces, or changing a role, resyncs the affected sockets.
- `status_changed` no longer fires on a cross-list move that keeps the status name.
- Side effects are fitted to the Queues 128KB message limit instead of being dropped.
- `automation_runs` and `audit` are pruned.
- The importer retries rows that failed instead of recording them as loaded.

### Dependencies

- wrangler 4.151, with patched sharp.
- source-map-js pinned to 1.2.2.
- Patched hono, the MCP SDK, prosemirror-view and proxy-addr.
- vitest 5.
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ points:
via an extract → transform → load pipeline with checkpoints; an Asana or
Trello extractor that emits the same intermediate shape reuses transform and
load for free.
- **New MCP tools** — if an agent workflow you run needs a capability the 15
- **New MCP tools** — if an agent workflow you run needs a capability the 18
existing tools don't cover, propose it in an issue; tool names live in
`packages/shared/src/api.ts` and implementations in `apps/api/src/mcp/`.
See [docs/AGENTS_AND_MCP.md](docs/AGENTS_AND_MCP.md).
Expand Down
27 changes: 25 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
# Flow

[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/joelborch/flow)

**Agent-first project management you self-host on Cloudflare.** A drop-in replacement for the ClickUp/Asana/Linear slice most teams actually use — spaces, lists, boards, tasks, subtasks, comments, attachments, automations — built so that AI agents are first-class users, not an afterthought bolted onto a REST API.

**[Try the read-only demo](https://flow-demo.joel-a67.workers.dev)** (sample data, nothing to install), or deploy your own copy with the button above.

<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/board-dark.png">
<img src="docs/assets/board.png" alt="A Flow board: four status columns of task cards with due dates, subtask progress, tags and assignees" width="900">
</picture>
</p>

One Cloudflare Worker serves the SPA, the REST API, an MCP server, and WebSockets. One Durable Object holds the entire workspace in SQLite. The Cloudflare free tier covers a small team: Workers, one DO, R2 for attachments, Queues for side effects. Your realistic monthly bill is $0–5, against $7–19 per seat per month for the SaaS tools it replaces.

## Why it exists
Expand Down Expand Up @@ -33,6 +43,17 @@ Task managers are slow, expensive per seat, and hostile to agents. Flow inverts
- **ClickUp import** — a three-pass extract/transform/load pipeline with deterministic ids, so re-running is an upsert, not a duplicate workspace
- **Dark mode, keyboard layer, instant loads** — Preact + Signals SPA, ~no dependencies, boot cache, early WebSocket open from an inline script in `index.html`

<table>
<tr>
<td width="50%"><img src="docs/assets/task-handoff.png" alt="A task panel showing an agent handoff: who asked, what they want back, its state, and the description"></td>
<td width="50%"><img src="docs/assets/my-work.png" alt="My Work: overdue, today, this week and no-date buckets, with an assigned-out rail"></td>
</tr>
<tr>
<td align="center">An agent handoff on a task</td>
<td align="center">My Work, with what you've assigned out</td>
</tr>
</table>

## What it deliberately doesn't do

Flow replaces the slice of ClickUp a small team actually uses. The rest is left out on purpose, because each omission removes a permanent complexity tax:
Expand Down Expand Up @@ -70,7 +91,9 @@ Every mutation runs as one synchronous turn inside the DO: write rows, append on

## Quickstart

Prerequisites: Node 20+, [pnpm](https://pnpm.io), a Cloudflare account, `wrangler` (installed as a dev dependency).
**One click.** The [Deploy to Cloudflare](https://deploy.workers.cloudflare.com/?url=https://github.com/joelborch/flow) button copies the repo into your GitHub account, creates the Durable Object, R2 bucket and queues, and deploys to a `*.workers.dev` address. Until sign-in is set up, Flow shows the remaining steps: create two Cloudflare Access applications, then set `ACCESS_TEAM_DOMAIN`, `ACCESS_AUD` and `OWNER_EMAIL` under the Worker's Settings → Variables ([details](docs/SELF_HOSTING.md#5-cloudflare-access)). The button deploys from the root `wrangler.jsonc`; for a custom domain and email, use the manual setup below. If the Cloudflare account already runs a Flow, rename the bucket and both queues on the button's setup page, because it preselects existing resources with the default names.

**By hand.** Prerequisites: Node 20+, [pnpm](https://pnpm.io), a Cloudflare account, `wrangler` (installed as a dev dependency).

```bash
git clone https://github.com/joelborch/flow.git
Expand Down Expand Up @@ -141,7 +164,7 @@ packages/core The Workspace Durable Object: SQLite schema, mutation turns, aut

## Contributing

PRs welcome. Ground rules: TypeScript strict everywhere, `pnpm typecheck` and `pnpm test` must pass, no new runtime dependencies without a strong reason (the whole app currently runs on hono, zod, preact, and @preact/signals), and contract changes go through `packages/shared`. Good first contributions: an Asana or Trello extractor for the importer ([docs/IMPORTING.md](docs/IMPORTING.md#future-work-asana-and-trello) shows exactly where it plugs in), and @-mention parsing (the notification preference already exists and is waiting for an emitter).
PRs welcome. Ground rules: TypeScript strict everywhere, `pnpm typecheck` and `pnpm test` must pass, no new runtime dependencies without a strong reason (the whole app currently runs on hono, zod, preact, and @preact/signals), and contract changes go through `packages/shared`. Good first contributions: an Asana or Trello extractor for the importer ([docs/IMPORTING.md](docs/IMPORTING.md#future-work-asana-and-trello) shows exactly where it plugs in), and @-mention parsing (the notification preference already exists and is waiting for an emitter). [ROADMAP.md](ROADMAP.md) lists what's planned and what's deliberately out of scope.

## License

Expand Down
38 changes: 38 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Roadmap

No dates on purpose: Flow is maintained alongside other work, and a date would be a guess. Each item says where the need comes from, so it can be checked against the repo. Want to help? The items marked as good first issues are a good place to start, and anything under Later deserves an issue to discuss the design first.

## Now

- **Docs catch-up.** A few docs fell behind recent changes: the MCP tool count, the runtime dependency list, the core package README, and the automation template fields. Open as good first issues.
- **Importer catch-up sync.** A `load --resend` flag so a cutover can re-send rows that changed in ClickUp after the first load without duplicating attachments. A good first issue.
- **Tests for small pure helpers.** Fractional ordering, the importer's rate limiter, and the web app's date formatters. Open as good first issues.

## Next

- **@-mentions.** The `mention` notification preference already exists and defaults on, but nothing parses mentions or emits the event yet (`packages/shared/src/notifications.ts`). This needs a parser, a delta or side-effect carrying the mention, and the email template.
- **Member management in the UI.** Today, users beyond the first owner go in through the admin import endpoint (`POST /api/import/batch` with a `users` array, per `docs/SELF_HOSTING.md`). Inviting, deactivating and changing roles from Settings would remove the last routine curl from setup.
- **Asana and Trello importers.** The importer's extract/transform/load seams are already documented in `docs/IMPORTING.md`; a new source needs its own extractor and transform mapping and reuses id derivation, validation and load unchanged.
- **MCP directory listings.** An official MCP Registry entry first, since other directories ingest from it. The open question is how to list a server that every team hosts at its own URL.
- **Handoff state notifications.** Today a requester hears about a `needs_input` question through the ordinary comment email. A notification for the state change itself would make the hand-back explicit.
- **Streamed backups.** The daily backup builds the whole NDJSON dump in memory before gzipping it (`runBackup` in `packages/core/src/index.ts`). Streaming it would raise the workspace size at which backups stop fitting.

## Later (open an issue to discuss first)

- **OAuth for the MCP server.** Clients and directories increasingly expect OAuth; Flow only supports static bearer keys, minted per user. Any OAuth design has to keep the current property that a key acts as one real user with an audit trail.
- **A guest or read-only role.** The permission model is members plus private spaces. Some teams need to show a client a board without making them a member.
- **Better mobile.** The app is responsive web only. An installable PWA with notifications is the likely step before anything native.
- **A per-user resync floor.** The workspace-wide floor in `packages/core/src/sync-floor.ts` sends a spurious snapshot to clients whose view didn't change. That's cheap today; it matters only if workspaces get much larger.

## Not planned

These are left out by design, and the reasons are in `README.md` and `docs/COMPARISON.md`. Each would add complexity to every query, form and API shape, so issues asking for them will be closed with a pointer to this list:

- Gantt charts, timelines and workload views
- Time tracking
- Custom fields
- Folders (spaces contain lists)
- Multiple assignees per task
- Docs, whiteboards, chat and goals
- More than one workspace per deployment
- A runtime other than Cloudflare Workers and Durable Objects
24 changes: 21 additions & 3 deletions apps/api/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,35 @@ import { hashToken, looksLikeApiToken, parseBearer } from "./tokens.js";
* its id lands in the audit trail.
*
* Unauthenticated requests are rejected on /api/* and /mcp. The only exceptions
* are /api/health (liveness) and /api/inbound/* (authenticated per-list by the
* list's own inboundToken, inside the route).
* are /api/health (liveness), /api/setup (whether sign-in is configured, a
* single word), and /api/inbound/* (authenticated per-list by the list's own
* inboundToken, inside the route).
*/

/** Paths that skip this middleware entirely. */
export function isPublicPath(pathname: string): boolean {
if (pathname === "/api/health") return true;
if (pathname === "/api/health" || pathname === "/api/setup") return true;
if (pathname === "/api/inbound" || pathname.startsWith("/api/inbound/")) return true;
return false;
}

/**
* How people sign in to this deployment, for the public GET /api/setup. A
* fresh deploy (the Deploy to Cloudflare button, or wrangler.example.jsonc
* copied as-is) has no Access AUD yet, so every request 401s; reporting
* "unconfigured" lets the web app show the setup steps instead of sending the
* browser to an Access login page that doesn't exist on this hostname.
*/
export function signInMode(
env: Pick<Env, "ACCESS_TEAM_DOMAIN" | "ACCESS_AUD" | "DEV_NO_AUTH">
): "access" | "dev" | "unconfigured" {
if (env.DEV_NO_AUTH === "true") return "dev";
const aud = (env.ACCESS_AUD ?? "").trim();
const team = (env.ACCESS_TEAM_DOMAIN ?? "").trim().toLowerCase();
const teamSet = team.endsWith(".cloudflareaccess.com") && team !== "your-team.cloudflareaccess.com";
return /^[0-9a-f]{64}$/i.test(aud) && teamSet ? "access" : "unconfigured";
}

function actorForUi(user: User): Actor {
return { userId: user.id, via: "ui", apiKeyId: null, automationRuleId: null };
}
Expand Down
9 changes: 7 additions & 2 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/// <reference types="@cloudflare/workers-types" />
import { Hono } from "hono";
import { Workspace } from "@flow/core";
import { authMiddleware } from "./auth.js";
import { authMiddleware, signInMode } from "./auth.js";
import { cachedBackupHealth } from "./do.js";
import { handleEmail } from "./email-intake.js";
import { onError, notFoundHandler } from "./errors.js";
Expand Down Expand Up @@ -49,9 +49,14 @@ app.get("/api/health", async (c) => {
}
});

// Whether sign-in is configured yet: "access", "dev" (DEV_NO_AUTH) or
// "unconfigured". Public and a single word, so the web app can ask before it
// redirects a 401 to Access (see signInMode in auth.ts).
app.get("/api/setup", (c) => c.json({ signIn: signInMode(c.env) }));

/**
* Auth gate for everything that is not the SPA. `authMiddleware` lets
* /api/health and /api/inbound/* through (the latter authenticates per-list
* /api/health, /api/setup and /api/inbound/* through (the latter authenticates per-list
* against the list's own token) and rejects everything else without a valid
* Cloudflare Access JWT or `flow_` bearer token.
*/
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { ToolContext } from "./context.js";
import { registerFlowTools } from "./tools.js";

const SERVER_NAME = "flow";
const SERVER_VERSION = "0.1.0";
const SERVER_VERSION = "0.2.0";

/**
* Read by the client before it has called anything, and often truncated — some
Expand Down
46 changes: 46 additions & 0 deletions apps/api/src/setup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, it, vi } from "vitest";
import { isPublicPath, signInMode } from "./auth.js";
import type { Env } from "./env.js";

// GET /api/setup tells the web app whether sign-in is configured, so a fresh
// deploy shows its setup steps instead of redirecting to an Access login that
// doesn't exist on that hostname. The entrypoint pulls in the DO class, whose
// base class only exists in workerd.
vi.mock("cloudflare:workers", () => ({
DurableObject: class {},
WorkerEntrypoint: class {},
}));

const AUD = "a".repeat(64);

describe("signInMode", () => {
it("is access once a real team domain and AUD are set", () => {
expect(signInMode({ ACCESS_TEAM_DOMAIN: "acme.cloudflareaccess.com", ACCESS_AUD: AUD })).toBe("access");
expect(signInMode({ ACCESS_TEAM_DOMAIN: " Acme.CloudflareAccess.com ", ACCESS_AUD: ` ${AUD.toUpperCase()} ` })).toBe(
"access"
);
});

it("is unconfigured for a fresh deploy's empty or placeholder values", () => {
expect(signInMode({ ACCESS_TEAM_DOMAIN: "", ACCESS_AUD: "" })).toBe("unconfigured");
expect(signInMode({ ACCESS_TEAM_DOMAIN: "your-team.cloudflareaccess.com", ACCESS_AUD: AUD })).toBe("unconfigured");
expect(signInMode({ ACCESS_TEAM_DOMAIN: "acme.cloudflareaccess.com", ACCESS_AUD: "" })).toBe("unconfigured");
expect(signInMode({ ACCESS_TEAM_DOMAIN: "acme.example.com", ACCESS_AUD: AUD })).toBe("unconfigured");
expect(signInMode({ ACCESS_TEAM_DOMAIN: "acme.cloudflareaccess.com", ACCESS_AUD: "not-hex" })).toBe("unconfigured");
});

it("is dev when DEV_NO_AUTH is on, whatever Access says", () => {
expect(signInMode({ ACCESS_TEAM_DOMAIN: "", ACCESS_AUD: "", DEV_NO_AUTH: "true" })).toBe("dev");
});
});

describe("GET /api/setup", () => {
it("is public and answers with one word", async () => {
expect(isPublicPath("/api/setup")).toBe(true);
const { default: worker } = await import("./index.js");
const env = { ACCESS_TEAM_DOMAIN: "", ACCESS_AUD: "", ASSETS: { fetch: vi.fn() } } as unknown as Env;
const res = await worker.fetch(new Request("https://flow.example.com/api/setup"), env, {} as ExecutionContext);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ signIn: "unconfigured" });
});
});
4 changes: 4 additions & 0 deletions apps/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@
in src/lib/early-ws.ts). -->
<script>
(function () {
// The static demo build (VITE_DEMO=true, see src/lib/demo.ts) has no
// server to connect to. Vite replaces %VITE_DEMO% at build time and
// leaves it as-is when the variable is unset.
if ("%VITE_DEMO%" === "true") return;
try {
var raw = localStorage.getItem("flow.boot");
var m = raw && raw.slice(0, 200).match(/"seq":(\d+)/);
Expand Down
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"build:demo": "VITE_DEMO=true vite build --outDir dist-demo",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
Expand Down
Loading
Loading