Destination Recipes
Wire Paintless into GitHub, GitLab, Jira, Linear, Slack and anything else with a URL.
A destination is where a request ends up. Six ship first-party — github,
gitlab, jira, linear, slack and webhook — and the webhook is the escape
hatch that reaches everything else. This page is the copy-paste layer on top of
Configuration.
How delivery is wired
Destinations are declared once and then named by routes:
export default defineConfig({
destinations: [github({ repo: 'acme/shop' })],
routes: [{ when: { env: 'prod' }, deliver: ['github'] }],
})Two rules catch almost every "nothing was delivered" report:
- A route must list
deliver. Declaring a destination is not enough —resolveDestinationsreturns nothing for a route without adeliverarray. - Routes match top-down, first match wins. A broad
{ when: { env: 'prod' } }rule above a narrow one makes the narrow one dead.
Omit run on a route and the agent is skipped entirely: the raw request is
delivered as a ticket the moment it is submitted. That is no-agent mode, and
it is the cheapest way to adopt Paintless.
Naming destinations
As an array, destinations are named by their adapter: 'github', 'gitlab',
'jira', 'linear', 'slack', 'webhook'. That is enough until you need the same adapter twice — two
repositories, two Linear teams, two relays — because both entries would answer to
the same name.
Declare them as a record and the keys become the names, exactly like agents:
export default defineConfig({
destinations: {
shop: github({ repo: 'acme/shop' }),
infra: github({ repo: 'acme/infra' }),
slack: webhook('https://relay.acme.dev/slack'),
},
routes: [
{ when: { env: 'prod', role: 'infra' }, deliver: ['infra', 'slack'] },
{ when: { env: 'prod' }, deliver: ['shop', 'slack'] },
],
})Receipts come back under the alias — { destination: 'infra', url: ... } — so the
requester and your logs see which target actually answered. Both forms work; the
array stays the shorter option when each adapter appears once.
GitHub issues
No agent, no server — a PAT is enough.
import { github } from '@paintless/dest-github'
export default defineConfig({
destinations: [github({ repo: 'acme/shop', labels: ['paintless', 'ux'] })],
routes: [{ when: { env: 'prod' }, deliver: ['github'] }],
})GITHUB_TOKEN is read from the environment when token is omitted. For teams,
pass auth with GitHub App credentials instead — installation tokens are minted
and cached, which avoids a personal token in your infrastructure.
GitHub pull requests
mode: 'pr' is a different animal. A PR delivers an ExecutionResult, not a
ChangeRequest — there must be an agent run and a pushed branch behind it, so
this path needs the self-hosted server:
destinations: [github({ repo: 'acme/shop', mode: 'pr', base: 'main' })],
routes: [{ when: { env: 'prod' }, run: 'default', deliver: ['github'] }],Set PAINTLESS_PUSH_REMOTE=origin so the runner pushes paintless/<id> before
the PR is opened — in pr mode a result with no branch is an error, because there
is nothing to open the PR from. In the default issue mode the applied change is
filed as an issue instead, so a route that runs an agent never loses its result.
GitLab
import { gitlab } from '@paintless/dest-gitlab'
destinations: [gitlab({ project: 'acme/shop', labels: ['paintless'] })],
routes: [{ when: { env: 'prod' }, deliver: ['gitlab'] }],project takes the path (group/subgroup/name, URL-encoded for you) or the
numeric project ID. The token comes from GITLAB_TOKEN when token is omitted,
and needs the api scope. For a self-managed instance, point apiUrl at it:
gitlab({ project: 1234, apiUrl: 'https://gitlab.acme.dev/api/v4' })In prod, deliver an applied change as a merge request instead:
destinations: [gitlab({ project: 'acme/shop', mode: 'mr', target: 'main' })],
routes: [{ when: { env: 'prod' }, run: 'default', deliver: ['gitlab'] }],Same rule as GitHub: set PAINTLESS_PUSH_REMOTE=origin so the runner pushes
paintless/<id> first; in mr mode a result with no branch is an error, and in
the default issue mode the applied change is filed as an issue. Receipts carry
GitLab's own notation — #12 for an issue, !12 for a merge request.
Linear
import { linear } from '@paintless/dest-linear'
destinations: [linear({ teamId: 'FRONT' })],
routes: [{ when: { env: 'prod', role: 'reporter' }, deliver: ['linear'] }],LINEAR_API_KEY is read from the environment when apiKey is omitted.
Jira
import { jira } from '@paintless/dest-jira'
destinations: [jira({ site: 'https://acme.atlassian.net', projectKey: 'SHOP' })],
routes: [{ when: { env: 'prod' }, deliver: ['jira'] }],Cloud authenticates with an account email plus an API token — JIRA_EMAIL and
JIRA_API_TOKEN when the options are omitted. Give only the token and the
adapter sends it as a bearer PAT, which is what Server/Data Center expects.
issueType defaults to Task; set it to a type your project actually has or
Jira rejects the create call.
Applied results are filed as issues too — Jira has no merge request to map onto,
so pair it with github/gitlab on the same route when you want both a ticket
and a PR.
Descriptions are rendered in Jira's own wiki markup (h2., ||Field||Value||,
{code}), not the markdown the other destinations get — REST v2 stores the field
verbatim, so markdown tables would reach the ticket as literal text. A format
hook receives that markup as its default.
Slack
import { slack } from '@paintless/dest-slack'
destinations: [slack({ channel: '#product-feedback' })],
routes: [{ when: { env: 'prod' }, deliver: ['slack'] }],SLACK_BOT_TOKEN is read from the environment when token is omitted; the bot
needs chat:write and membership in the channel. With no bot to install, point
it at an incoming webhook instead — the channel is then fixed by the hook:
slack({ webhookUrl: process.env.SLACK_WEBHOOK_URL })The message is composed as Block Kit rather than reusing the markdown ticket
body: a header, the comment as a quote, the source location, component trail,
selector and a link to the page, then a context line carrying the request ID.
chat.postMessage receipts carry the message ts as their externalId;
incoming webhooks return no identifier, so the receipt carries only the name.
Everything else: the webhook relay
webhook() POSTs a single, stable JSON envelope:
{
"type": "change_request",
"payload": { "comment": "...", "element": { "sourceLocation": {...} }, "page": {...} }
}type is change_request for raw requests and execution_result after an agent
run. Because the shape never changes, a ~20 line relay reaches any product with
an API. Point Paintless at your relay:
import { webhook } from '@paintless/dest-webhook'
destinations: [webhook('https://relay.acme.dev/paintless', {
headers: { authorization: `Bearer ${process.env.RELAY_TOKEN}` },
})],
routes: [{ when: { env: 'prod' }, deliver: ['webhook'] }],Notion
The relay in one piece — read the envelope, then call the product's API:
import { createServer } from 'node:http'
createServer((req, res) => {
let body = ''
req.on('data', (chunk) => (body += chunk))
req.on('end', async () => {
const { payload } = JSON.parse(body)
await createPage(payload)
res.writeHead(200).end('{}')
})
}).listen(8080)createPage for a Notion database is one call:
await fetch('https://api.notion.com/v1/pages', {
method: 'POST',
headers: {
authorization: `Bearer ${process.env.NOTION_TOKEN}`,
'notion-version': '2022-06-28',
'content-type': 'application/json',
},
body: JSON.stringify({
parent: { database_id: process.env.NOTION_DATABASE_ID },
properties: {
Name: { title: [{ text: { content: payload.comment.slice(0, 100) } }] },
URL: { url: payload.page.url },
Source: {
rich_text: [{ text: { content: payload.element.selectorPath } }],
},
},
}),
})Match the properties keys to your database's actual columns — Notion rejects
unknown property names.
Discord
Discord's incoming webhook will not take the envelope as-is — it requires at
least one of content, embeds, components, file or poll, so a raw POST
comes back 400. The mapping is small:
await fetch(`${process.env.DISCORD_WEBHOOK_URL}?wait=true`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
username: 'Paintless',
embeds: [{
title: payload.comment.slice(0, 256),
url: payload.page.url,
fields: [
{ name: 'Source', value: `\`${source}\``, inline: true },
{ name: 'Selector', value: `\`${payload.element.selectorPath}\``, inline: true },
],
}],
}),
})content is capped at 2000 characters and embeds at 10 — a request comfortably
fits one embed. The endpoint answers 204 No Content by default, so it hands
back no link; ?wait=true returns the created message instead, which is what
lets your relay reply with a url the receipt can carry.
Teams, anything else
Every one of them is the same pattern: read payload, map the fields you care
about, POST to the product's API. The interesting fields are almost always
comment, element.sourceLocation, element.selectorPath, page.url,
reporter and screenshot.
The one rule worth internalising: almost nothing accepts the envelope
directly. Slack's incoming webhook rejects it with no_text, Discord with a
400, Notion needs auth headers and a properties map. The relay exists because
the mapping is three lines, not because it is optional.
Customising the ticket text
github, gitlab, jira, linear and slack accept a format whose hooks receive the default
rendering, so extending it is one line rather than a rewrite:
github({
repo: 'acme/shop',
format: {
title: (req) => `[FE] ${req.comment.split('\n')[0]}`,
body: (req, defaultBody) =>
`Priority: ${req.reporter?.role === 'support' ? 'high' : 'normal'}\n\n${defaultBody}`,
result: {
title: (result, defaultTitle) => `[bot] ${defaultTitle}`,
body: (result, defaultBody) => `Agent: ${result.agent}\n\n${defaultBody}`,
},
},
})A route that runs an agent delivers an ExecutionResult, not the request — the
PR, or the ticket describing the change that was applied. format.result shapes
that half; without it the title is whatever the agent called its own run.
Fan-out and failure isolation
A route can name several destinations:
routes: [
{ when: { env: 'prod', role: 'reporter' }, deliver: ['linear'] },
{ when: { env: 'prod' }, run: 'default', deliver: ['github', 'linear', 'webhook'] },
]Delivery is not all-or-nothing: a destination that throws reports an error to the client and the remaining destinations still receive the request. Receipts come back for the ones that succeeded, so a Slack outage never costs you the PR.
Test it before you wire up an account
Point a destination at a local receiver and read the payload. No tokens, no external service, and it verifies your routes at the same time:
// receiver.mjs — node receiver.mjs
import { createServer } from 'node:http'
createServer((req, res) => {
let body = ''
req.on('data', (chunk) => (body += chunk))
req.on('end', () => {
console.dir(JSON.parse(body), { depth: null })
res.writeHead(200).end('{}')
})
}).listen(9911, () => console.log('listening on http://localhost:9911'))// paintless.config.mjs
destinations: [webhook('http://localhost:9911')],
routes: [{ when: { env: 'dev' }, deliver: ['webhook'] }],Run paintless dev, click an element, submit. The dev host reports
status: delivered with a receipt, and the receiver prints the exact payload any
integration would receive. Look at this payload before building a ticket
template — its quality is what the ticket's quality will be.
When to write a real adapter
Relays are fine for one team. Write an adapter when you want the integration
reusable, named in deliver, and covered by tests:
npm create paintless-adapterThe Destination contract is one method — deliver(input) => DeliveryReceipt —
and @paintless/adapter-kit ships the contract tests it must pass. See
Writing Adapters.
