Skip to content

Add the fetch battery: call a third-party API without its key in the bundle - #28

Merged
max-bader merged 1 commit into
mainfrom
claude/bool-fetch-battery
Aug 5, 2026
Merged

Add the fetch battery: call a third-party API without its key in the bundle#28
max-bader merged 1 commit into
mainfrom
claude/bool-fetch-battery

Conversation

@max-bader

Copy link
Copy Markdown
Collaborator

client.fetch lets a deployed app call a third-party API using a key its owner stored with Bool, without the key entering the app bundle. Write {{SECRET_NAME}} wherever the key belongs — the URL, a header value, the body — and the gateway's fetch plane substitutes the real value server-side.

const res = await bool.fetch(
  "https://api.example.com/v1/things?key={{EXAMPLE_API_KEY}}",
);
if (!res.ok) return;              // the API's status, exactly like fetch
const data = await res.json();

await bool.fetch("https://api.example.com/v1/things", {
  method: "POST",
  headers: { Authorization: "Bearer {{EXAMPLE_API_KEY}}" },
  body: JSON.stringify({ name }),
});

Why it's shaped like fetch

It takes the same arguments as the global fetch and resolves to a real Response carrying the third party's status, headers and body, so res.ok, res.status and res.json() mean what they always mean. The only new thing to learn is the placeholder.

The alternative was an object-shaped call returning { status, headers, body }. That reads as more explicit about the proxy being there, but it makes every caller relearn a shape they already know — and the cost is paid at every call site, forever. Matching fetch keeps the API surface to one idea.

Two details worth reviewing

A response from the API is never an error, including a 4xx. BoolFetchError is thrown only when the request was never made — secret_not_set, unknown_secret, host_not_allowed, rate_limited, out_of_app_credits — mirroring how fetch throws on a network failure but not on a 404. The error carries secrets, the key names involved, so an app can tell its user which key is still missing rather than failing opaquely.

204/205/304 come back with a null body. The Response constructor rejects a body on a null-body status, so passing one through would turn a successful DELETE into a thrown error.

Notes for the reviewer

  • The call resolves through the gateway base like every other plane, not a relative URL. The editor preview runs on a different origin from the gateway, so a relative call would only start working once an app was published — the worst shape for this kind of bug.
  • aiHeaders is renamed batteryHeaders. Same envelope (preview viewer token, end-user session, local-development key), now shared by both batteries instead of being named after one. Internal to createBoolClient; no export changes.
  • New exports: BoolFetch, BoolFetchInit, BoolFetchError.
  • body is typed as string, as with fetchJSON.stringify your object. FormData, streaming bodies and AbortSignal are deliberately not forwarded, since the call is described to the gateway as data rather than opened from the browser.

Versioning

0.4.0 — purely additive, so apps on ^0.3.x are unaffected until they reinstall. Requires a gateway with the fetch plane enabled for the workspace; until then the plane is not routable and calls fail closed.

Testing

bun run typecheck clean, bun test 179 pass / 0 fail, bun run build emits the battery into dist/ with fetch: BoolFetch on the published BoolClient type. Eleven new tests cover the wire shape, Headers/URL inputs, third-party status passthrough, JSON vs text bodies, the 204 case, both throw paths, the identity headers, and that the request never goes out relative.

🤖 Generated with Claude Code

…ubstitution

Lets a deployed app call a third-party API using a key its owner stored with
Bool, without the key entering the app bundle. Write {{SECRET_NAME}} wherever the
key belongs — the URL, a header value, the body — and the gateway's fetch plane
substitutes the real value server-side.

  const res = await bool.fetch(
    "https://api.example.com/v1/things?key={{EXAMPLE_API_KEY}}",
  );
  if (!res.ok) return;
  const data = await res.json();

Shaped as fetch on purpose. It takes the same arguments as the global fetch and
resolves to a real Response carrying the third party's status, headers and body,
so res.ok / res.status / res.json() mean what they always mean and the only new
thing to learn is the placeholder. An object-shaped API returning {status, body}
was the alternative; it reads as more explicit but every caller then has to
relearn a shape they already know, and every generated call site pays for that.

Two details worth knowing:

- A response from the API is never an error, including a 4xx. BoolFetchError is
  thrown only when the request was never made (secret_not_set, unknown_secret,
  host_not_allowed, rate_limited, out_of_app_credits), mirroring how fetch throws
  on a network failure but not on a 404. It carries `secrets`, the key names
  involved, so an app can tell its user which key is still missing rather than
  failing opaquely.

- 204/205/304 come back with a null body. The Response constructor rejects a body
  on those statuses, so passing one through would turn a successful DELETE into a
  thrown error.

The call routes through the resolved gateway base like every other plane, not a
relative URL: the editor preview runs on a different origin from the gateway, so
a relative call would only work once an app was published. `aiHeaders` becomes
`batteryHeaders` — same envelope (preview viewer token, end-user session,
local-development key), now shared by both batteries rather than named after one.

Minor release, purely additive: apps on ^0.3.x are unaffected until they
reinstall.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@max-bader
max-bader marked this pull request as ready for review August 5, 2026 15:27
@jonahc44

jonahc44 commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Heads up — this PR and #29 both claim 0.4.0, and they both edit src/client.ts. Flagging the collision rather than touching this branch.

Versioning

We landed on this PR moving to 0.5.0 and #29 keeping 0.4.0. The reasoning, in case you'd rather argue it than accept it:

  • Type the AI plane's error codes; normalize retryAfter to a Date #29 is not purely additive — it changes what an unreadable error body reports (ai_failedunknown_error). Apps retry on ai_failed, so that behavior change wants a version signal, and in 0.x the minor is the slot for it.
  • The fetch battery is purely additive, so renumbering it costs a package.json line, a CHANGELOG heading, and the Versioning section of the PR body — no code, no tests.
  • There's a third consumer: the platform repo gates what the system prompt teaches on the project's installed SDK minor. Type the AI plane's error codes; normalize retryAfter to a Date #29 adds a floor keyed to minor 4. If the AI error codes shipped as anything below that, the gate can't see them.

^0.5.0 satisfies that floor too, so nothing downstream cares which of these two ends up higher — only that they're different.

The merge conflict

Three files conflict textually, so whichever merges second needs a rebase:

  • src/client.ts — the real one. aiHeaders()batteryHeaders() rewrites the headers: line inside ai.stream, and Type the AI plane's error codes; normalize retryAfter to a Date #29 adds an error path to that same function (a mid-stream failure can't be status-mapped, so the read loop now throws stream_interrupted). Same hunk, different changes.
  • CHANGELOG.md — both insert a new top heading, both currently named ## 0.4.0.
  • package.json — same version line.

#29 is the smaller diff, so it's cheaper for it to go first and for this branch to rebase onto it. Happy either way — say which and I'll do the rebase.

One design question, unrelated to the above

BoolFetchError uses rate_limited and out_of_app_credits, which are also two of the ten AI-plane codes #29 types as BoolAiWireErrorCode. That's the gateway being consistent, and it's the right call — out_of_app_credits names the credit pool, so every battery drawing on it should report the same string for the same condition.

Worth deciding deliberately, though: are these two error surfaces meant to share a code vocabulary, or just happen to overlap on the two codes that come from shared infrastructure? If it's the former there's a case for a common type; if the latter, the duplication is fine and a shared type would imply a guarantee neither plane makes. Nothing to change here either way — just easier to answer now than after both are published.

@jonahc44

jonahc44 commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Correction to my comment above: ignore the versioning ask — this PR keeps 0.4.0. #29 has moved to 0.5.0 instead. Nothing for you to change; sorry for the noise.

The rest of that comment still stands: the src/client.ts / CHANGELOG.md / package.json conflicts are unchanged (except that the CHANGELOG headings no longer collide on the same number), and whichever merges second still needs a rebase.

One consequence worth recording, since it's the kind of thing that goes stale silently: the platform-repo gate that decides whether a project's prompt teaches the AI error codes is keyed to SDK minor 5, not 4 — because 0.4.0 is this PR and carries none of those codes. A ^0.4.x app gets bool.ai plus the generic try/catch it can honor, which is correct. There's a test pinning ^0.4.x as not error-code-capable so that off-by-one can't creep back in.

@max-bader
max-bader merged commit 1e6f356 into main Aug 5, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants