Configuration
paintless.config — agents, destinations, and routing rules.
paintless.config.{ts,js,mjs} at your project root configures both the local
dev host (paintless dev) and the self-hosted server. It is plain TypeScript —
no DSL, just imports and functions.
// paintless.config.ts
import { claudeCode, command, defineConfig } from 'paintless'
import { github } from '@paintless/dest-github'
import { linear } from '@paintless/dest-linear'
export default defineConfig({
agents: {
default: claudeCode(),
aider: command('aider --message {prompt} --yes'),
},
destinations: [
github({ repo: 'acme/shop', mode: 'pr' }),
linear({ teamId: 'FRONT' }),
],
routes: [
{ when: { env: 'dev' }, run: 'default' },
{ when: { env: 'prod', role: 'reporter' }, deliver: ['linear'] },
{ when: { env: 'prod' }, run: 'default', deliver: ['github', 'linear'] },
],
})Agents
agents maps names to AgentAdapters. default is used when no route says
otherwise.
claudeCode({ model, maxTurns, settingSources })— the Claude Agent SDK editing files directly.settingSourcesdecides which filesystem settings the run loads; it defaults to['project']so the developer's personal~/.clauderules cannot widen the agent past the repo root. See Security.command(template, { name, env, promptVia })— wraps any CLI agent. The template is tokenized shell-style (no shell is spawned) and{prompt}is replaced with the built agent prompt:command('aider --message {prompt} --yes'). Windows.cmd/.batagents run through cmd.exe and must take the prompt on stdin —command('aider.cmd --yes', { promptVia: 'stdin' }). The factory refuses the unsafe combination rather than let request text reach a shell; see Security.
Destinations
destinations lists where results and requests can be delivered — an array named
by each adapter, or a record whose keys name them (use the record when the same
adapter appears twice). First-party:
| Factory | Delivers |
|---|---|
github({ repo, mode, base, labels, format }) | Structured issues; mode: 'pr' opens PRs from pushed runner branches |
gitlab({ project, mode, target, labels, format }) | Structured issues; mode: 'mr' opens merge requests from pushed runner branches |
jira({ site, projectKey, issueType, labels, format }) | Structured Jira issues (REST v2) |
linear({ teamId, format }) | Structured Linear issues |
slack({ channel, token, webhookUrl, format }) | A Block Kit message, through a bot token or an incoming webhook |
webhook(url, { headers }) | Raw JSON POST — the universal escape hatch |
Copy-paste setups for these, plus Notion, Discord and anything else through a webhook relay, live in Destination Recipes.
Ticket-style destinations accept a format option to customize the title and
body while receiving the default rendering:
github({
repo: 'acme/shop',
format: {
title: (req) => `[FE] ${req.comment.split('\n')[0]}`,
body: (req, defaultBody) => `Priority: high\n\n${defaultBody}`,
// Delivered after an agent applied the change — a PR, or a ticket
// describing the applied result rather than the original request.
result: {
title: (result, defaultTitle) => `[bot] ${defaultTitle}`,
body: (result, defaultBody) => `Agent: ${result.agent}\n\n${defaultBody}`,
},
},
})title/body shape the request as filed by a reporter; result shapes what
an applied change looks like at the same destination. Configure both when a
route both runs an agent and delivers, or the agent's own summary is the title
your team sees.
Routes
Routes are evaluated top-down; the first matching when wins.
when.env—'dev' | 'prod'when.role— matchesrequest.reporter.rolerun— agent name to execute; omit it to skip the agent entirely and deliver the raw request as a ticket (no-agent mode)deliver— destination names to fan out to
A route with deliver but no run is how "PM clicks a button, a structured
Linear ticket appears, no code is touched" works.
A destination only fires when a route names it in deliver — declaring it in
destinations alone delivers nothing.
