Embedding the board
From nothing to a working board, in the order you actually do it. Every step below is exercised by this repo's own smoke, which boots an Express app and talks to it over HTTP.
Everything between
<and>is yours.
Try it first#
pnpm example (from a clone, after pnpm install) serves the finished result on http://localhost:5190: the API from step 5 with an in-memory store and a fixed demo user, and every screen from step 9, on a made-up project. The model is a fake one unless examples/basic/.env.local holds ANTHROPIC_API_KEY or OPENAI_API_KEY. Two files are the whole integration, and the steps below explain each line of them:
examples/basic/server/app.ts: ports,createBoardDeps,createBoardServer,mountExpress.examples/basic/client/host.tsx: theBoardHost(transport, router, links, user).
1. The shortest thing that works#
No database, no forge, no model — just to see it answer:
import express from 'express';
import { mountExpress } from '@dev-bloom/boardkit-server';
import { createTestServer } from '@dev-bloom/boardkit-server/testing';
const { server } = createTestServer();
const app = express();
app.use(express.json());
const router = express.Router();
mountExpress(router, server);
app.use('/api/admin/backlog', router);
app.listen(4000);curl localhost:4000/api/admin/backlog/board
curl -X POST localhost:4000/api/admin/backlog/tasks \
-H 'content-type: application/json' -d '{"title":"The first one"}'That is the whole integration surface. Everything below replaces a stub with something real.
2. Naming your board#
The package ships neutral: keys read TASK-1 and the AI persona is empty, because a library cannot be named after whoever used it first. Three environment variables, read by boardConfigFromEnv, say what the board is for:
| Variable | Default | What it does |
|---|---|---|
BOARD_KEY_PREFIX | TASK | The letters in front of every key: ACME-90. Changing it on a board that already has tasks orphans the old keys. |
BOARD_BRAND_NAME | (empty) | The brand the AI works for. |
BOARD_PRODUCT_DESCRIPTION | (empty) | What that brand sells, following the name after a comma: "the assistant for <brand>, <description>". |
import { boardConfigFromEnv } from '@dev-bloom/boardkit-core';
const config = boardConfigFromEnv(); // or boardConfigFromEnv(myEnv)Whether an empty persona is acceptable is yours to decide, not the library's: a board that only serves the API can live without one, while a host whose AI writes proposals should assert it at boot, where the operator can still read the message:
if (!config.persona.brandName || !config.persona.productDescription) {
throw new Error('BOARD_BRAND_NAME and BOARD_PRODUCT_DESCRIPTION are required.');
}A blank value counts as unset, so BOARD_KEY_PREFIX=" " still gives you TASK.
Everything else that names an installation#
No default names a product, a domain or a repository layout. These are the values a host that already runs agents, previews or a monorepo sets itself; left alone, each one degrades to "not configured" rather than to somebody else's value:
| Key | Default | Env var | What the default does |
|---|---|---|---|
mcp.name | boardkit | — | The MCP server's name, which prefixes every tool an agent sees (mcp__<name>__claim_task). A host whose agents are already configured keeps the name they use. |
release.previewUrls.web / .admin | '' | RELEASE_PREVIEW_WEB_URL, RELEASE_PREVIEW_ADMIN_URL | No "try it" links on the releases screen or the task page. |
dispatch.adminUrl | '' | ADMIN_APP_URL | A dispatched issue links the task by path alone (/tablero/t/ACME-7). |
dispatch.taskPath | /tablero/t/{key} | — | Matches createBoardLinks()'s default; change both together. |
dispatch.docsHint | '' | — | The issue leaves out "Conecta el MCP: ver …". |
dispatch.labels | ['board'] | — | The labels on every dispatched issue, before the task type. |
repo.apiPaths | [] | — | Path prefixes whose change can only be tried in production; none flags no task. |
repo.localeSegments | [] | — | Leading route segments the route finder strips (/es/catalogo → /catalogo). |
boardConfigFromEnv() returns a plain object, so the keys without a variable are set on it before it is handed to anything:
const config = boardConfigFromEnv();
config.mcp.name = '<board-name>';
config.dispatch.labels = ['<label>'];
config.repo.localeSegments = ['es', 'en'];The @ picker's context registry is neutral too: new AppContextService(providers, { tenantId }) asks every provider for tenantId (default 'default'), and lists groups in the order of the providers you pass, so a group is named by the provider that brings it.
3. Persistence#
Copy prisma/board.prisma from @dev-bloom/boardkit-prisma into your schema, add the sequence to a migration, migrate, and hand the store over:
CREATE SEQUENCE "BacklogTaskKeySeq" START 1;import { PrismaBoardStore } from '@dev-bloom/boardkit-prisma';
const store = new PrismaBoardStore(prisma);To be sure it works before you trust it, run the store contract from @dev-bloom/boardkit-testing against your database. Details in that package's README.
4. Who is asking#
The board never looks at a token. You resolve identity:
const server = createBoardServer({
...board, // step 5
principals: (req) => {
const user = req.session?.user; // however your host does it
if (!user) return null; // → 401, and the board never sees it
return { id: user.id, name: user.name, kind: 'admin', role: 'admin' };
},
});Two roles matter to the board: agent sees the twenty-one working tools, and anything else counts as an automation key, which also sees the three release tools. Guard the routes however you already guard your admin — the board assumes whoever reaches it was allowed to.
You also owe it a PrincipalDirectory: given an id, who that is. The board does not know your users table.
5. Services, and mounting them#
createBoardDeps wires every service the handlers and the agent tools read, from the ports only you can answer. Build it once at startup; it never throws and never awaits, so a missing key fails one request instead of the boot.
import { createBoardDeps, createBoardServer } from '@dev-bloom/boardkit-server';
import { createBoardLlm } from '@dev-bloom/boardkit-ai-sdk';
import { GithubScmProvider, RepoReader } from '@dev-bloom/boardkit-github';
import { useRepoIndex } from '@dev-bloom/boardkit-core';
useRepoIndex(<your committed repo-index.json, or null>); // before createBoardDeps
const scm = new GithubScmProvider(config);
const board = createBoardDeps({
store, // step 3
directory: <your PrincipalDirectory>, // step 4: who an id is
audit: <your AuditSink>, // where an audit row goes
config, // step 2
llm: createBoardLlm({ config }), // Claude Sonnet 5.5 unless BACKLOG_AI_MODEL says otherwise
scm,
repo: new RepoReader(scm, <the same index>, config),
storage: <your StorageProvider>, // attachment bytes
});
const server = createBoardServer({ ...board, principals: <step 4> });Each optional port has a default that keeps the rest of the board serving:
| Option | Without it |
|---|---|
llm | the interview answers 503 anthropic_unconfigured |
scm, repo | dispatch, releases and the code routes answer 503 github_unconfigured |
storage | uploads are refused with a 503 |
notifier | the bell stays in the app |
refine | POST /tasks/:id/refine answers 503 |
rateLimiter, turnBus, changes | in memory, one instance (createPostgresBoardChangeBus from @dev-bloom/boardkit-prisma crosses instances) |
contextProviders | the board's vocabulary and the repo index's routes; a list you pass replaces them |
budget | BacklogBudgetService; pass a subclass to credit spend |
logger | warnings and errors to the console |
board.prSync is what runPrSync needs, for whatever cron you have.
The model. createBoardLlm follows config.ai.model: Claude by default (with config.secrets.anthropicApiKey, i.e. ANTHROPIC_API_KEY), gpt-* on OpenAI (openaiApiKey, OPENAI_API_KEY) and gemini-* on Google (googleApiKey, GOOGLE_GENERATIVE_AI_API_KEY). The interview checks the key of that same provider before every turn, so an OpenAI or Gemini board needs no Anthropic key. Pass resolveFile to let a conversation started on Claude keep its attachments on another provider.
The language. config.language (es by default, or en) is the language the AI writes in: the interview, the task chat and the task actions (enrich, rewrite criteria, split). A language without a bundle falls back to Spanish.
Mounting. One table of routes, three ways to serve it, all in the same order:
// Express (behind your own auth middleware)
mountExpress(router, server);
app.use('/api/admin/backlog', router);// Nest: one catch-all method replaces every controller
@Controller('admin/backlog')
@UseGuards(<YourAdminGuard>)
export class BoardController {
constructor(private readonly server: BoardServer) {}
@All('*path')
@UseInterceptors(FileInterceptor('file')) // the two attachment routes
route(@Req() req: Request, @Res() res: Response) {
return handleNestRequest(this.server, req, res, {
basePath: '/api/admin/backlog',
toError: (status, body) => new HttpException(body as object, status),
});
}
}// Next.js App Router: app/api/board/[...path]/route.ts
export const { GET, POST, PATCH, DELETE } = createNextRouteHandlers(server, {
basePath: '/api/board',
getPrincipal: async (request) => <who is asking, or null>,
waitUntil: after, // from next/server
});
export const runtime = 'nodejs';The Nest and Next adapters are the whole route, so every row but the public roadmap answers 401 when the principal is null. In Nest, use @Res() without passthrough (the adapter writes the response, streams included), register any host-only controller before the catch-all, and pass toError so your interceptors see a failure as a throw. matchBoardRoute(boardRoutes(), method, path) tells you which row a request reaches, if an interceptor needs to know. The Next adapter reads JSON and multipart/form-data (field file) and serves MCP over the SDK's Web transport, which needs @modelcontextprotocol/sdk 1.25 or later.
6. The forge#
scm and repo in step 5 are the forge, read from GITHUB_BACKLOG_TOKEN and GITHUB_BACKLOG_REPO. Generate the index with npx boardkit index (or boardkit-index) and commit it. Without it the agent's code tools ask the forge for everything.
7. Agents over MCP#
mountExpress publishes the MCP endpoint at /mcp when you pass tools. Then boardkit setup mcp prints exactly what a person pastes to connect Claude Code or Codex; the whole flow, including the branch convention and the etiquette, is in agents.md.
8. Dispatching a task to an agent#
boardkit setup dispatch lists the permissions and secrets to create, and boardkit setup workflow prints the workflow file that receives a dispatched task. Both are documented in agents.md §7.
9. The screens#
@dev-bloom/boardkit-react draws the board. It knows nothing about your framework: one object — the host — carries your HTTP client, your session, your router and your links, and the screens read it from context.
import {
BoardHostProvider,
KanbanScreen,
createBoardLinks,
type BoardHost,
} from '@dev-bloom/boardkit-react';
import { createBoardApi } from '@dev-bloom/boardkit-core/client';
import '@dev-bloom/boardkit-react/styles.css';
const host: BoardHost = {
transport: { baseUrl: '<the API origin, or "" on the same one>', fetchJson, getToken, refresh }, // the board adds /api/admin/backlog
api: createBoardApi(fetchJson),
useUser: () => session.user,
can: (action) => policy(session.role, action),
links: createBoardLinks('/tablero'),
router: { navigate, usePathname, useSearchParams },
useLocale: () => 'es',
};
<BoardHostProvider host={host}>
<div className="bk-root"><KanbanScreen /></div>
</BoardHostProvider>;can(action) hides what the role cannot do (hidden, not disabled). Keep it in step with your server's own checks: write (every create and edit: drag a card, add a subtask or comment, upload, edit fields, new task or epic; without it the board is read-only), ship (releases), verify (the requester's verdict on a task in production, separate from ship), dispatch (send to an agent, refine), manageEpics (archive an epic), delete (remove a subtask, comment or attachment). can: () => true shows everything.
Actions are never inferred from one another: a host written before delete or verify existed must add them for the roles its server allows (a host whose can returns true for full-access roles only needs the lines for the other roles). Moving a card into En producción is not a can matter: the core only assigns that column through a release, and the board never sends it.
One screen per route, and createBoardLinks(base, { segments }) is what says where each one lives, so the base path and every segment are yours to choose (the defaults below are the board's original routes):
| Route | Screen |
|---|---|
<base>/kanban | KanbanScreen |
<base>/backlog | BacklogScreen |
<base>/epicas | EpicsScreen |
<base>/lanzamientos | ReleasesScreen |
<base>/actividad | ActivityScreen |
<base>/t/<key> | TaskScreen |
<base>/nueva[/<conversationId>] | IntakeScreen |
createBoardLinks('/board', { segments: { epics: 'epics', releases: 'releases', activity: 'activity', task: 'tasks', intake: 'new' } });Amounts and dates follow the host's locale through format.intlLocale(locale): the AI spend is billed in US dollars, so it reads US$ 0,65 for es-CO and $0.65 for en-US.
The i18n keys keep their tablero.* namespace: renaming it would silently drop every override a host has written. That rename waits for a major.
.bk-root is required — including on anything you portal — and the optional members of the host (Link, features, github, dispatch, useToast, useConfirm, format, t, auth, screens) each have a documented fallback. The full contract, the token list and what happens when a member is missing are in the package's README; @dev-bloom/boardkit-react/testing mounts the same screens on an in-memory host, which is what the stories and the package's own suite use.
Styling and strings are already covered: theming.md and i18n.md.
Screens that fit the viewport#
From 901 px up, the task list, Épicas and Lanzamientos fill the screen: the page does not scroll, the count and pager stay at the bottom, and the rows scroll inside the table under a sticky header. The height is 100dvh − --bk-topbar-h − 32 px (48 px more between 901 and 1139 px, where the section head shows). Put the screen in a body whose bottom padding is zero (.bk-frame-body does this for you) and keep the document itself unscrolled. If your chrome is not the top bar alone, set --bk-page-height on an ancestor (for example calc(100dvh - 120px)) and it replaces the calculation. At 900 px and below the page scrolls as before.