Connect your project

One deployment (apps/cloud) hosts the board for many projects ("tenants"). A project keeps only two things:

  1. a token endpoint behind its own login, which signs a short-lived JWT for the signed-in person, and
  2. a BoardHost whose transport points at the cloud.

Self-hosting with the packages (library mode, embedding.md) is unchanged; the cloud is just one more host of the same packages.

URLs#

For a tenant with slug acme on https://cloud.example.com:

WhatURL
Board APIhttps://cloud.example.com/t/acme/api/admin/backlog/...
Team (assignee picker)https://cloud.example.com/t/acme/api/admin/team (members and active agent keys; any role, same token)
MCP (agents)https://cloud.example.com/t/acme/mcp
Public roadmaphttps://cloud.example.com/t/acme/api/roadmap (only when the tenant opts in, see below)

An unknown or disabled tenant answers 404 {"error":"tenant_not_found"}.

1. Sign the board token (project backend)#

The boardkit team gives you the tenant's signing secret once. Keep it server-side. Expose an endpoint, e.g. GET /board-token, behind your own session, that returns a token for the signed-in person:

import { SignJWT } from 'jose';

const secret = new TextEncoder().encode(process.env.BOARDKIT_SIGNING_SECRET); // the string as printed

export async function boardToken(user: { id: string; name: string; email?: string; avatar?: string }, role: 'admin' | 'member' | 'viewer') {
  return new SignJWT({ name: user.name, email: user.email, avatar: user.avatar, role })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuer('acme') // the tenant slug
    .setAudience('boardkit-cloud')
    .setSubject(user.id) // stable id, ≤ 200 chars
    .setIssuedAt()
    .setExpirationTime('10m') // at most 15 minutes
    .sign(secret);
}

Rules the cloud enforces: HS256 only, iss equal to the slug in the URL, aud = boardkit-cloud, exp - iat ≤ 900 s (30 s clock tolerance), name non-empty, avatar kept only when it is https:. Anything else is a 401.

Roles, checked on the server for every request:

RoleCan
viewerread only (GET, including the AI turn stream)
memberread and write, except: any DELETE, /tasks/:id/dispatch, /tasks/:id/refine, /tasks/:id/verify, and every /releases* write
admineverything

Each verified token upserts a member (name, email, avatar, role) so the board can show who did what.

2. Point the BoardHost at the cloud (project frontend)#

Same host as in embedding.md §9; only the transport changes. baseUrl is the tenant prefix: the board appends /api/admin/backlog itself.

const BASE = 'https://cloud.example.com/t/acme';
let token: { value: string; exp: number } | null = null;

async function refresh(): Promise<boolean> {
  const res = await fetch('/board-token', { credentials: 'include' }); // your own endpoint
  if (!res.ok) return false;
  const { token: value } = (await res.json()) as { token: string };
  token = { value, exp: JSON.parse(atob(value.split('.')[1]!.replace(/-/g, '+').replace(/_/g, '/'))).exp };
  return true;
}

const getToken = () => token?.value ?? null;

async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
  // Renew a minute before expiry.
  if (!token || token.exp * 1000 - Date.now() < 60_000) await refresh();
  const res = await fetch(`${BASE}${path}`, {
    ...init,
    headers: { 'content-type': 'application/json', ...(getToken() ? { Authorization: `Bearer ${getToken()}` } : {}), ...init?.headers },
  });
  const body = res.status === 204 ? null : await res.json().catch(() => null);
  if (!res.ok) throw Object.assign(new Error(body?.message ?? res.statusText), { status: res.status, body });
  return body as T;
}

const host: BoardHost = {
  transport: { baseUrl: BASE, fetchJson, getToken, refresh, live: true },
  api: createBoardApi(fetchJson),
  // useUser, can, links, router, useLocale: as in embedding.md
};

live: true opens the board's change stream (GET /board/events, viewers included). Changes travel over Postgres LISTEN/NOTIFY on channel boardkit_<slug>, so a tab hears a change made through any cloud instance. The stream is cut after a while and the client reconnects on its own. Set live: false to turn it off.

CORS#

The board API answers cross-origin requests only from the tenant's allowedOrigins (exact match, e.g. https://admin.acme.com, no trailing slash). Preflight is 204; methods GET, POST, PATCH, DELETE; headers Authorization, Content-Type, Idempotency-Key, Last-Event-ID. No cookies are used or allowed (no Allow-Credentials): auth is the bearer token. MCP has no CORS.

Connect page (portal)#

The cloud portal opens a project's board as the person signed in there. Each person connects a project once (every 14 days): the portal sends them to your connect page with ?state=...&return=..., and the page answers with a connect token posted back to the cloud. Send its URL to the boardkit team to register.

// Behind your own login (send signed-out people to it and back to this URL).
app.get('/boardkit/connect', async (req, res) => {
  const callback = 'https://cloud.example.com/connect/acme/callback';
  const { state, return: ret } = req.query;
  if (typeof state !== 'string' || !state || ret !== callback) return res.status(400).end(); // never post elsewhere
  const token = await new SignJWT({ name: req.user.name, role: roleOf(req.user), state })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuer('acme')
    .setAudience('boardkit-cloud-connect') // not 'boardkit-cloud'
    .setSubject(req.user.id) // the same sub as your board tokens
    .setIssuedAt()
    .setExpirationTime('120s') // at most 120 s
    .sign(secret);
  res.set('cache-control', 'no-store').send(
    `<form method="post" action="${callback}"><input type="hidden" name="token" value="${token}"></form><script>document.forms[0].submit()</script>`,
  );
});

The token goes in a form POST, never in a URL. email and avatar are optional claims, as in the board token. A working example with two local projects: examples/cloud-project.

3. Agents (MCP)#

The boardkit team creates an agent key per agent (shown once, bkc_<slug>_...). The agent connects to https://cloud.example.com/t/acme/mcp with Authorization: Bearer <key> (or X-API-Key: <key>). A key only works on its own tenant; a revoked key is a 401. Only the key's sha256 is stored.

Public roadmap#

Off by default; ask the boardkit team to turn it on for your project (publicRoadmap set to exactly true). Then GET /t/acme/api/roadmap answers the epics marked public, with no token, Cache-Control: public, s-maxage=300 and Access-Control-Allow-Origin: * (no credentials). That is the URL core's getRoadmap calls with baseUrl = https://cloud.example.com/t/acme. Off, it answers the same 404 as an unknown tenant.