Connect your project
One deployment (apps/cloud) hosts the board for many projects ("tenants"). A project keeps only two things:
- a token endpoint behind its own login, which signs a short-lived JWT for the signed-in person, and
- a
BoardHostwhose 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:
| What | URL |
|---|---|
| Board API | https://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 roadmap | https://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:
| Role | Can |
|---|---|
viewer | read only (GET, including the AI turn stream) |
member | read and write, except: any DELETE, /tasks/:id/dispatch, /tasks/:id/refine, /tasks/:id/verify, and every /releases* write |
admin | everything |
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.