Specbook
05 · AI

Describe the flow. Then just run it.

specbook init puts a guide in your project and points your agent's instruction file at it, and specbook add reads your spec's security and sets up its credentials. A coding agent then writes requests and flows the way your team would: typed from the spec, checked, and runnable on mock data. This page is one real run, against Plaid's spec with its 350 operations.

Set up the project

Install Specbook, run specbook init, then specbook add the spec. Adding a spec downloads it and reads its security schemes.

Terminal window
npm install --save-dev @specbook/cli @specbook/core
specbook init # config, a tsconfig and an agent guide; adds typescript and faker to package.json
npm install # fetches what init added
specbook add plaid --spec https://raw.githubusercontent.com/plaid/plaid-openapi/master/2020-09-14.yml --base-url https://sandbox.plaid.com

Plaid asks for three headers, so add wrote one profile that sends all three, and a secret for each. Plaid accepts OAuth too, so it wrote a profile for that, and one more that fetches a token with a client id and secret.

✓ downloaded https://raw.githubusercontent.com/plaid/plaid-openapi/master/2020-09-14.yml → openapi/plaid.yml (3.3 MB, sha256 ea443cd78f6f)
✓ added "plaid" to .specbook/specbook.config.ts
✓ credentials from the spec: 3 ways to authenticate, the first is the default
profile "plaid" sends PLAID_CLIENT_ID, PLAID_SECRET, PLAID_VERSION
profile "plaid-oauth2" takes an access token in PLAID_OAUTH2
profile "plaid-oauth2-client" signs in server-side with PLAID_OAUTH2_CLIENT_ID and PLAID_OAUTH2_CLIENT_SECRET
set the secrets as environment variables or in .specbook/environments/<env>.secrets.env; the mock needs none
Plaid-Version reads like a version, not a credential: put its value in PLAID_VERSION
plaid-oauth2-client needs a server to hold its secret (dev, test and bundle runs); a static portal offers plaid-oauth2 instead
✓ other servers became environments in .specbook/environments/:
sandbox.env plaidUrl=https://sandbox.plaid.com
✓ plaid: 350 operations → ~/my-api/.generated/plaid
specbook.config.ts, as add wrote it
auth: {
plaid: {
// from the spec's security: clientId, secret, plaidVersion
title: 'API keys',
scheme: ['clientId', 'secret', 'plaidVersion'],
apply: {
headers: {
'PLAID-CLIENT-ID': '{{secrets.PLAID_CLIENT_ID}}',
'PLAID-SECRET': '{{secrets.PLAID_SECRET}}',
'Plaid-Version': '{{secrets.PLAID_VERSION}}',
},
},
},
services: {
plaid: {
spec: './openapi/plaid.yml',
baseUrl: '{{plaidUrl}}',
auth: ['plaid', 'plaid-oauth2', 'plaid-oauth2-client'],
},

The first profile is the default, and the service lists them all. The values come from your environment, never from a file, and the mock needs none. add also says what it left for you: Plaid-Version reads like a version and not a credential.

Ask for a request

That is the whole setup. This is the whole prompt.

Add a request that creates a Plaid sandbox public token.

The agent read the guide and the spec, then wrote one request. It left the config alone, because the credentials were already there. It captures the public token, because the next step will need it.

sandbox-public-token-create.req.ts
import { SandboxPublicTokenCreate } from '#api/plaid'
export default SandboxPublicTokenCreate.request({
name: 'Create a sandbox public token',
tags: ['smoke'],
// First Platypus Bank, one of Plaid's Sandbox test institutions.
data: {
institution_id: 'ins_109508',
initial_products: ['transactions'],
},
capture: {
publicToken: (res) => (res.status === 200 ? res.data.public_token : undefined),
},
assert: (res, { assert }) => {
assert.is(res.status, 200, 'public token created')
if (res.status === 200) {
assert.type(res.data.public_token, 'string', 'response has a public_token')
assert.match(res.data.public_token, /^public-/, 'token looks like a public token')
}
},
})

Ask for a flow

The second prompt says what should end up working. It does not list the calls.

Now make a flow that links a bank account in the Plaid sandbox and reads back its accounts and transactions.

The agent found the four operations in the spec, wrote a request for each so every step also runs on its own, and chained them in one flow.

link-bank-account.seq.ts
import { sequence } from '@specbook/core'
import getAccounts from '../requests/plaid/accounts-get.req'
import exchangeToken from '../requests/plaid/item-public-token-exchange.req'
import createPublicToken from '../requests/plaid/sandbox-public-token-create.req'
import getTransactions from '../requests/plaid/transactions-get.req'
const isoDay = (d: Date) => d.toISOString().slice(0, 10)
export default sequence({
id: 'link-bank-account',
title: 'Link a bank account in the Plaid sandbox',
description:
'Create a sandbox public token, exchange it for an access token, then read back the accounts and transactions.',
tags: ['plaid', 'smoke'],
})
.step('Create a sandbox public token', createPublicToken) // captures `publicToken`
.step('Exchange the public token', (ctx) =>
exchangeToken.with({ data: { public_token: ctx.captured.publicToken ?? '' } }),
) // captures `accessToken`, `itemId`
.step('Get the accounts', (ctx) =>
getAccounts.with({ data: { access_token: ctx.captured.accessToken ?? '' } }),
) // captures `accountIds`
.step('Get the transactions', (ctx) =>
getTransactions.with({
data: {
access_token: ctx.captured.accessToken ?? '',
start_date: isoDay(new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)),
end_date: isoDay(new Date()),
},
}),
) // captures `transactionAccountIds`, `totalTransactions`
.check('Transactions belong to the linked accounts', (ctx, { assert }) => {
for (const id of ctx.captured.transactionAccountIds) {
assert.ok(
ctx.captured.accountIds.includes(id),
`transaction account ${id} is one of the linked accounts`,
)
}
})

Values pass by name, and the names are checked

The first request captures publicToken. The next step sends it as public_token, the exchange captures accessToken, and the last two steps send that. The agent wrote each hand-off as ctx.captured.publicToken, not as a {{publicToken}} string.

A string compiles whatever it says. ctx.captured.publicToken has the type the capture returned, and the field it lands in is typed from the spec, so a wrong name on either side is an error in your editor. A .check step proves the chain: every transaction it reads is on one of the accounts it read.

Rename a capture

Rename publicToken to publicTokenX in the first request, then run the compiler and the tests.

$ npx tsc --noEmit
sequences/link-bank-account.seq.ts(17,61): error TS2551: Property 'publicToken' does not exist on type '{ publicTokenX: string | undefined; }'. Did you mean 'publicTokenX'?
$ npx specbook test --grep "Link a bank"
Flows 1 passed (1)
Steps 5 passed
Assertions 12 passed

The compiler points at the broken step. The test run still passes on the mock, so a green run does not prove the names line up. That is why the guide ends with npx tsc --noEmit, and why the agent reads values from ctx and not from strings.

It runs on mock data

npx specbook test runs the flow against the mock, which answers from the spec's examples. No server and no keys.

specbook test 0.12.0 env mock · 1/5 tests
✓ Link a bank account in the Plaid sandbox › Create a sandbox public token POST /sandbox/public_token/create 200 1ms
✓ Link a bank account in the Plaid sandbox › Exchange the public token POST /item/public_token/exchange 200 0ms
✓ Link a bank account in the Plaid sandbox › Get the accounts POST /accounts/get 200 1ms
✓ Link a bank account in the Plaid sandbox › Get the transactions POST /transactions/get 200 0ms
✓ Link a bank account in the Plaid sandbox › Transactions belong to the linked accounts
Flows 1 passed (1)
Steps 5 passed
Assertions 12 passed
Duration 8ms

Setup took a few seconds, and the agent needed about two minutes to get from there to a passing flow, with no fixes by hand. That was one run, so yours will vary. Running it against Plaid's live sandbox needs a free account's keys, set as PLAID_CLIENT_ID, PLAID_SECRET and PLAID_VERSION, and that part is not tested here.

Do the same in your project

Run specbook init and your agent has the guide. It also writes a short Specbook section to your AGENTS.md, so agents that read that file open the guide before they touch a request. Then specbook add your spec, and its credentials are set up too.

Terminal window
npx specbook init # writes .specbook/agent-guide.md, and an AGENTS.md if you have none
npx specbook init --agents # adds the Specbook section to an AGENTS.md or CLAUDE.md you already have

Agents that read from the web can fetch /llms.txt, which is the same guide with links to these pages. Write your own prompts the way these were written: say what should end up working, and name the API in plain words. The agent finds the operations in the spec.