Imported from thomasfosterau/effect-ai-ext (
AGENTS.md). Install upstream withnpx skills add thomasfosterau/effect-ai-ext. Copyright stays with the author.
AGENTS.md
CLAUDE.md is a symlink to this file. This repo publishes the @thomasfosterau/effect-ai-* family and effect-evals: purely additive extensions to Effect's AI module (effect/ai), consumed by the owner's applications.
Toolchain: the Vite+ library standard the sibling libraries share, copied from effect-web (#58): vp runs every task, vp check is the format (Oxfmt) and lint (Oxlint with the Effect preset) gate, vp pack (tsdown) builds, tests import from vite-plus/test, docgen compiles and runs every JSDoc @example against the built dist/, and the fallow gate runs before every agent commit and push.
| Package | Directory | Purpose · peers |
|---|---|---|
@thomasfosterau/effect-ai-ext |
packages/effect-ai-ext |
Provider-neutral abstractions upstream Effect lacks (reranker, classifier, TTS, STT, image, moderation, voice conversion), each written as if it could merge into effect/ai; one subpath export per module · effect |
@thomasfosterau/effect-ai-gateway |
packages/effect-ai-gateway |
Routing layer: repoints other providers' clients at a Cloudflare AI Gateway route (Worker binding or public HTTPS endpoint), BYOK; not itself a provider · effect, @effect/ai-anthropic, optional @cloudflare/workers-types |
@thomasfosterau/effect-ai-{mistral,cohere,voyage,elevenlabs} |
packages/effect-ai-<vendor> |
First-party providers over each vendor's own API; portable, so a routing layer repoints them through apiUrl · effect |
@thomasfosterau/effect-evals |
packages/effect-evals |
Effect-native LLM evaluation toolkit and the effect-evals CLI · effect, optional @effect/platform-node (CLI only) |
Common pitfalls
- Upstream boundary. Write no local package for a provider the Effect org already ships (
@effect/ai-anthropictoday; OpenAI/Google if published); repoint the upstream package instead, aseffect-ai-gateway/src/Anthropic.tsdoes. effectis a singleton. Two copies breakContext/Layer/Schemaidentity, so every published package declares it as a peer (catalog:peers), takes it as acatalog:devDependency for building and testing, andoverrides: { effect: "catalog:" }forces every resolution onto that one copy.@effect/*packages a published package needs are peers too, never exact-pinned dependencies: their owneffectpeer would reject a consumer's older rc.- The Effect line moves as one.
effect,@effect/ai-anthropic,@effect/platform-nodeand the transitive@effect/platform-node-sharedshare one exact rc in the default catalog. Bump@effect/platform-node-sharedby hand: only the catalog andoverridesname it, so Dependabot never proposes it. - The published peer floor is open, never an exact pin:
catalog:peersdeclareseffect: ">=4.0.0-rc.118", per the shared Effect-floor policy (effect-jsonapi#115). rc.118 is the first rc with theeffect/unstable/*→effect/*path move (#51). Re-verify by pointing the default catalog at the floor before moving it; raising the floor is a breaking change for every package. @effect/tsgo,@typescript/native,typescriptandvite-plusbump together.@effect/tsgoships artifacts for an explicit set of TypeScript and Oxlint versions (effect-tsgo patch --typescript --oxlintpatches both) and failspnpm installwithUnsupportedTargetPackageVersionErroron any other;vite-pluspins theoxlint,oxfmtandvitestit carries, sovite,vitestand@vitest/coverage-istanbulfollow it. Read the comments on the@effect/tsgoandvite-pluscatalog entries before bumping any of them.- Strict catalog. Every external version lives in
pnpm-workspace.yaml(catalogMode: strict); manifests carrycatalog:,catalog:peersorworkspace:*. After editing the catalog, runpnpm installand commitpnpm-lock.yaml, or every CI job fails withERR_PNPM_LOCKFILE_CONFIG_MISMATCH. minimumReleaseAgeholds a newly published version for a day beforepnpm installtakes it (Dependabot'scooldownmirrors it);@thomasfosterau/*is exempt.- The
apiUrloverride is a contract, not a convenience: it is how a routing layer (the gateway) repoints a provider's client for BYOK. Every provider has a test that pointsapiUrlat a stub server. - Errors leave a provider only as
AiError. No transport- or schema-level failure escapes a provider's public surface. effect-evalstype-checks more thansrc. Every other package'stsconfig.jsonincludessrconly (tests are validated by running them);effect-evalsalso checkstestsand its configs and setserasableSyntaxOnly, because Node type-strips its TypeScript (see the comment in itstsconfig.json). Its relative imports carry.ts; the other packages' are extensionless.- Ported comments cite their evidence. A bare
#NNNordocs/decisions/*.mdin a comment ported from an application names that application's tracker or docs (the website unless stated), not this repo. New comments cite this repo's issues as#NNNand others aswebsite#NNN.
Conventions
exactOptionalPropertyTypesis on: spell an option a caller may forwardundefinedfor as?: T | undefined.- A provider-neutral abstraction mirrors
effect/ai'sEmbeddingModel: aContext.Servicetag, aServiceinterface, amake(...)that validates provider results, a typedAiErrorchannel,Effect.withSpantelemetry and options/provider-options interfaces. The in-repo example iseffect-ai-ext/src/RerankerModel.ts. - A first-party provider mirrors
effect-ai-mistral:<Vendor>Client: aContext.ServiceoverHttpClientwith base-URL prefixing, an optionalRedactedkey, and amake/layer/layerConfigtriad.- Per-capability models implementing the relevant
effect/aioreffect-ai-extservice (LanguageModel,EmbeddingModel,RerankerModel, …). - Wire discipline: hand-written request interfaces (plain objects sent as JSON); lenient response decoding (
Schema.optional+Schema.NullOr, free strings for enums the mapper narrows) so a new vendor field never fails an otherwise-valid response;internal/errors.tsmapsHttpClientError/SchemaErrortoAiErrorthroughAiError.reasonFromHttpStatus.
- Barrels re-export each module as a namespace (
export * as MistralClient from "./MistralClient"). The gateway's barrel is flat (export *of each module) and re-exports noRerankerModelname; consumers import it from@thomasfosterau/effect-ai-ext/RerankerModel(#97). - Doc comments put the description first, including module headers: summary sentence, details, then tags (
@module,@copyright,@category, …). Hovers show only the text before the first tag, so a header that opens with@moduleor@copyrightshows no summary. Don't add@since: eachdocgen.jsonsetsenforceVersion: false, so docgen never asks for it. Every@exampleis compiled and run (vp run docgen), so it must type-check and run offline: build layers and programs, but don't execute anything that calls a vendor API. - Builds:
vp pack(tsdown: rolldown ESM + rolldown-plugin-dts on TS6), configured once invite.package.tsand instantiated by each package's one-callvite.config.ts.effect-ai-extbuilds one entry per module (its subpath exports), each from a build-only facade underpackages/effect-ai-ext/entries/(itsvite.config.tssays why);effect-evalsbuildssrc/index.tsand its CLI (dist/cli/main.js); the others buildsrc/index.tsonly.effect,@effect/*,@thomasfosterau/*,@cloudflare/*andnode:*stay external so consumers share one copy. Packages develop withexports→./src/index.ts;publishConfigrewritesexports/main/typestodiston publish, so tests and type checks resolve a sibling to itssrc/and no task needs a build first. - TypeScript 6/7 split:
@typescript/native(TS7, bintsc, patched by@effect/tsgoso it also reports Effect diagnostics) runsvp run check;typescript(aliased to@typescript/typescript6, bintsc6) is the JS-API compiler the language service andvp pack's declaration bundler load. Only Effecterrordiagnostics failtsc:suggestionnever affects the exit code, andtsconfig.base.jsonsetsignoreEffectWarningsInTscExitCodesowarningdiagnostics stay visible but don't failvp run checkeither, as in the sibling libraries (#67). - Format and lint:
vp checkruns Oxfmt and Oxlint (config in the rootvite.config.ts:@effect/tsgo'srecommendedpreset plus@mpsuesser/oxlint-plugin-effect, rule set copied from effect-web). Onlyerrorfails it. The rules listed under "ported-code allowances" sit atwarnbecause pre-existing code still trips them: a new site is a defect, not headroom, and a rule goes back toerroronce its count reaches zero. This repo's own adoption allowances are gone (#98); the rules its conventions cut across are set apart, each with its reason.CHANGELOG.mdfiles are never formatted (Changesets writes them). - Tests:
- No live-key or live-network tests: stub at
FetchHttpClient.Fetch(capture the request, return a cannedResponseor SSE stream) or at the abstraction'smakeseam. - Effect helpers live per package in
tests/helpers/effect.ts(runTest,runTestExit,expectFailureInstanceOf,recordingTracer); import fromvite-plus/test(nevervitest; thevite-plus/prefer-vite-plus-importslint rule enforces it) and provide layers per test withEffect.provide. - Streaming providers also prove backpressure (a chunked stub, no unbounded buffering) and interruption (interrupting the Effect cancels the in-flight HTTP stream).
- Coverage is a ratchet (#100). Each package's
vite.config.tsexports acoverageFloor(lines / branches / functions / statements), set from its measured coverage rounded down to the integer;vite.package.tsholds the shared target, effect-web's 90 / 85 / 90 / 90, which a metric the floor omits falls back to. The gate isvp run -r test:coverage, which measures each package with only its own tests and fails when one drops below its floor (CI runs it). The rootvp run test:coverageapplies the same floors as per-package glob thresholds, a weaker extra check: its map merges every package's tests, so another package's tests can hide a drop. When a change raises a package's coverage, raise its floor in the same PR; never lower a floor or exclude files to get a change through, add the tests instead. A new package adds its floor tocoverageFloorsin the rootvite.config.ts.
- No live-key or live-network tests: stub at
- No live credentials, tokens or secrets in code or fixtures.
Commands
pnpm install— dependencies (pnpm 11, pinned bypackageManager) plus the Vite+ git hooks (vp config; pre-commit runsvp staged: format + lint on staged files) and thepreparestep that patches the nativetscand theoxlintvite-pluscarries with@effect/tsgo(effect-tsgo patch --typescript --oxlint, which skips a binary that is already patched). No pre-push hook: run the gates yourself.vp check— format + lint (Oxfmt + Oxlint).vp check --fixapplies the fixes.vp run check— the type gate: native TS7tsc -p tsconfig.jsonper package. They are different commands.vp run -r test:unit— every package's unit suite (node Vitest; nothing here needs a credential).vp run -r build—vp pack→ every package'sdist/.vp run docgen— builds every package, then type-checks and runs every JSDoc@examplewith@effect/docgen(tsc+tsx) against the builtdist/(eachdocgen.jsonmaps the package names ontodist/throughexamplesCompilerOptions.paths) and writes the git-ignored API reference topackages/*/docs/. CI runs it.vp run check:dist— builds every package, then imports every built entry and subpath under Node ESM and fails if a value export the bundled.d.tsdeclares is missing at runtime (which the src-resolving suites cannot see), type-checks every bundleddist/**/*.d.tswithskipLibCheck: falseand fails on any diagnostic in the package's owndist(a dangling namespace or unresolved name a consumer would hit; third-party declarations' own noise is ignored, no package loads ambienttypes, so the gateway's.d.tsis proved to need nothing from@cloudflare/workers-types, #113), compiles a Worker-shaped probe against the gateway's built.d.tswith workers-types loaded to proveenv.AI: Aiis accepted unchanged, and launches theeffect-evalsbin from a packed tarball undernode_modules. It replays the cachedbuild, which overwrites a hand edit todist/, so a dist-level mutation proof runsnode scripts/check-dist.mjsdirectly after the build.vp run test:coverage— every package's unit suite under one Istanbul map atcoverage/coverage-final.json, whichfallow auditreads for exact per-function CRAP scores (without the map fallow scores every function as uncovered). Its per-package floor thresholds are only a weaker extra check, since every package's tests count toward each package;vp run -r test:coverageis the coverage gate, runs this root task too, and is what CI runs.pnpm audit --audit-level high— the dependency audit CI runs (network).pnpm changeset— add a changeset.
Invoke everything through vp (vp exec instead of npx; vp run <task> instead of bare pnpm). The sanctioned non-vp invocations: pnpm install (and pnpm install --frozen-lockfile in CI; the Release job adds --ignore-scripts, see PUBLISHING.md), pnpm audit, pnpm changeset / pnpm publish on the release path. vp is node_modules/.bin/vp; the SessionStart hook puts it on PATH.
Verification
Before you report work as done, run the gates below in order and quote each command with its final pass/fail. If a gate can't run here, say which and why in the PR body; never imply it ran. There is no pre-push hook.
- From a clean install, which is what CI sees:
rm -rf node_modules packages/*/node_modules packages/*/dist && pnpm install --frozen-lockfile. vp checkvp run checkvp run -r test:unitvp run -r test:coverage(the coverage floors)vp run -r buildvp run check:distvp run docgenpnpm audit --audit-level high(network)fallow audit --format json --quiet --explain --gate-marker agent(the fallow gate below; not a CI step), which reads the map step 5 wrote. The verdict must not befail.
Steps 2–9 are CI's ci job, in its order (ci.yml); its node-floor job reruns 4, 6 and 7 on Node 22.18.0.
Branches and pull requests
- Branch off
trunk; PR againsttrunk; squash-merge.trunkis the default branch; there is nomain. Name branchesissue-<n>-<slug>, one branch and PR per issue. Rebase ontoorigin/trunkbefore opening or updating a PR; never mergetrunkinto your branch (GitHub's Update-branch button merges). - Changesets. Every change to a published package's behaviour carries a changeset (
pnpm changeset); docs-, CI- and tooling-only changes need none. TSDoc ships in the published.d.ts, so a doc-comment change gets apatch. Never publish or version by hand: the Release workflow opens the version PR (chore: version packages) and the owner merges it. The pipeline and the owner's one-time setup are in PUBLISHING.md. - Standing PR approvals: once a branch has a pushed commit, open its PR without waiting to be asked; watch it to merge or close, investigating CI failures and review comments as they arrive and rebasing when it goes conflicting; keep the PR body current with what shipped against the issue's spec.
- GitHub scope. GitHub writes go to
thomasfosterau/effect-ai-extonly; pass-R thomasfosterau/effect-ai-extwhenever the cwd might be another checkout. Read other repos (the consuming applications included) for reference only. Use theghCLI for GitHub operations, even when GitHub MCP tools are available.
Work tracker
Issues are the backlog: when you find work you can't do now, file an issue rather than leaving a TODO. The issue is the spec — read its comments; later comments override the body. Issues are snapshots, so re-verify their claims against current trunk before editing. Record decisions and findings as issue comments. Stop-and-report is a valid outcome: on a blocker outside the issue's scope (an upstream effect/ai gap, a design conflict with effect-ai-ext's interfaces), comment the evidence on the issue and stop. Scope cut mid-PR becomes a linked issue before merge.
Effect
Consult effect-solutions before writing Effect code; never guess at an Effect pattern. It is not a dependency here and the SessionStart hook doesn't install it, so run it through pnpm dlx effect-solutions list, then pnpm dlx effect-solutions show <topic>… (needs network). When the guides run out, or offline, read the Effect source each package installs: packages/<pkg>/node_modules/effect/src. The pinned version is the effect entry in pnpm-workspace.yaml; at that rc the schema-backed error is Schema.TaggedError<Self>()(tag, fields) (Schema.TaggedErrorClass was the beta-line name).
Agent sessions
- The SessionStart hook (
.claude/hooks/session-start.sh, registered in.claude/settings.json) runspnpm install --frozen-lockfile(through Corepack when available), which also applies the tsgo patch, and putsvp(a re-exec shim that prefers the nearest checkout'snode_modules) andfallowon PATH for non-interactive shells, so the gates run without manual setup. It hydrates no secrets, and nothing here needs one. - A PreToolUse hook (
.claude/hooks/fallow-gate.sh) blocksgit commit/git pushwhenfallow audit --base origin/trunkfails; it reads the coverage map on disk, so runvp run test:coveragefirst. A PostToolUse hook runsvp fmton every source, JSON, CSS or Markdown file you write or edit. - Every gate in Verification runs in an agent session;
pnpm auditandpnpm dlx effect-solutionsneed network.
Documentation surfaces
README.md(root): landing page with the package table.packages/*/README.md: the API reference consumers read on npm.PUBLISHING.md: the release pipeline and the owner's one-time setup.- This file: agent guidance. Decisions go in issue comments.
Fallow local gate
Before any git commit or git push, run fallow audit --format json --quiet --explain --gate-marker agent. If the verdict is fail, fix the reported findings before retrying. Treat JSON runtime errors like { "error": true, ... } as non-blocking. Audit defaults to gate=new-only: only findings introduced by the current changeset affect the verdict. The config is .fallowrc.json: a new published entry point joins its entry list, and a new package adds a boundary zone and rule.
Fallow task map
| When the agent is about to... | Run |
|---|---|
| delete an "unused" export or file | fallow dead-code --trace <file>:<export> |
| delete an "unused" dependency | fallow dead-code --trace-dependency <name> |
| commit or open a PR | fallow audit --base <ref> |
| check which architecture rules apply to a file before changing it | fallow guard <files> |
| understand a finding | fallow explain <issue-type> |
