Paintless
Latest
/Ecosystem

Writing Adapters

Build a destination or agent adapter, verify it against the contract, publish it.

Every pluggable piece of Paintless is an adapter implementing one of the small interfaces in @paintless/protocol. Adapters are plain npm packages — publish one and any paintless.config.* can import it directly.

Quick start

npm create paintless-adapter jira                  # destination → paintless-dest-jira
npm create paintless-adapter aider --type agent    # agent       → paintless-agent-aider
cd paintless-dest-jira
npm install
npm test          # contract tests from @paintless/adapter-kit

The scaffold ships a working skeleton, a build setup, and a contract test. Fill in the TODOs, keep npm test green, publish.

The two adapter kinds

Destination — where results go

interface Destination {
  readonly name: string
  deliver(input: ExecutionResult | ChangeRequest): Promise<DeliveryReceipt>
}
  • A ChangeRequest input is no-agent mode: the user's request goes straight to your system as a ticket/issue. Render it with formatRequest(input, options.format) from the protocol: users get the consistent structured default, and a format?: RequestFormat option lets them override the title/body while receiving the default rendering — every first-party ticket destination follows this convention:

    github({
      repo: 'acme/shop',
      format: {
        title: (req) => `[FE] ${req.comment.split('\n')[0]}`,
        body: (req, defaultBody) => `Priority: high\n\n${defaultBody}`,
      },
    })
  • An ExecutionResult input is a delivered code change (result.branch carries the pushed branch). Deliver it as a PR, a ticket comment, a Slack message — or throw a clear error if your destination has no sensible mapping. Render it with formatResult(input, options.format), the other half of the same convention: format.result lets users shape the applied change the way format.title/format.body shape the request. A destination that reads only format.title/format.body silently ignores the user's configuration on every agent-run route, which is the path most teams actually use.

  • Return a DeliveryReceipt with a url whenever possible — it is shown to the requester as "your request became this".

  • Throw on failure; the pipeline reports it and keeps other destinations going.

AgentAdapter — what edits the code

interface AgentAdapter {
  readonly name: string
  execute(req: ChangeRequest, ctx: RepoContext): AsyncIterable<AgentEvent>
}

Contract (enforced by the kit):

  1. Emit started first and end with exactly one done or error.
  2. done.result.requestId must equal req.id.
  3. Never touch files outside ctx.root.
  4. Stream tool_use / log / diff events while working when you can — they power the live panel. Non-streaming agents may emit only starteddone.
  5. You may leave done.result.changes empty: the pipeline derives changed files from git. Build the prompt with buildAgentPrompt(req).

Tip: before writing a full adapter, check whether command('your-cli --flag {prompt}') from @paintless/agent-command already covers your tool.

Contract tests

@paintless/adapter-kit runs the rules above and returns the observed events/receipt for your own assertions. It is runner-agnostic (it throws ContractViolation errors), shown here with vitest:

import { sampleChangeRequest, verifyDestination } from '@paintless/adapter-kit'
import { expect, it } from 'vitest'
import { jira } from '../src/index.js'

it('conforms to the Destination contract', async () => {
  const receipt = await verifyDestination(jira({ fetchFn: mockedFetch }), {
    input: sampleChangeRequest(),
  })
  expect(receipt.url).toContain('atlassian.net')
})

Accept a fetchFn/client override in your options so tests never hit the network — every first-party adapter follows this pattern.

Conventions

KindPackage nameFactory export
Destinationpaintless-dest-<name><name>() returning Destination
Agentpaintless-agent-<name><name>() returning AgentAdapter
  • Depend only on @paintless/protocol (runtime) and @paintless/adapter-kit (dev).
  • Read secrets from an explicit option first, then a conventional environment variable (GITHUB_TOKEN-style); never log them.
  • Keep the factory synchronous — do IO lazily inside deliver/execute.
Edit this page

Last updated: