Paintless
Latest
/Pipeline

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:

  1. A route must list deliver. Declaring a destination is not enough — resolveDestinations returns nothing for a route without a deliver array.
  2. 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-adapter

The Destination contract is one method — deliver(input) => DeliveryReceipt — and @paintless/adapter-kit ships the contract tests it must pass. See Writing Adapters.

Edit this page

Last updated: