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-kitThe 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
ChangeRequestinput is no-agent mode: the user's request goes straight to your system as a ticket/issue. Render it withformatRequest(input, options.format)from the protocol: users get the consistent structured default, and aformat?: RequestFormatoption 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
ExecutionResultinput is a delivered code change (result.branchcarries 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 withformatResult(input, options.format), the other half of the same convention:format.resultlets users shape the applied change the wayformat.title/format.bodyshape the request. A destination that reads onlyformat.title/format.bodysilently ignores the user's configuration on every agent-run route, which is the path most teams actually use. -
Return a
DeliveryReceiptwith aurlwhenever 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):
- Emit
startedfirst and end with exactly onedoneorerror. done.result.requestIdmust equalreq.id.- Never touch files outside
ctx.root. - Stream
tool_use/log/diffevents while working when you can — they power the live panel. Non-streaming agents may emit onlystarted→done. - You may leave
done.result.changesempty: the pipeline derives changed files from git. Build the prompt withbuildAgentPrompt(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
| Kind | Package name | Factory export |
|---|---|---|
| Destination | paintless-dest-<name> | <name>() returning Destination |
| Agent | paintless-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.
