Paintless
Latest

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. settingSources decides which filesystem settings the run loads; it defaults to ['project'] so the developer's personal ~/.claude rules 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/.bat agents 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:

FactoryDelivers
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 — matches request.reporter.role
  • run — 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.

Edit this page

Last updated: