ai-task-runner-effect
v0.1.0
Published
An Effect-TS runner for named AI tasks: a task table (validator-agnostic codecs, per-transport prompts), a provider/credential contract the consumer implements, and one run(task, input) that branches between the Claude Code CLI (via claude-code-effect) an
Maintainers
Readme
ai-task-runner-effect
An Effect runner for named AI tasks. You write a
task table — each row an output contract, two prompt builders and the tools the
CLI may use — and get one run(task, input) that branches between two
transports:
claude-code— the local Claude Code CLI, throughclaude-code-effect(claude -p, tools, real sessions).anthropic/google/openai— hosted HTTP APIs, through the Vercel ai-sdk (generateObject, no tools).
Which provider a task runs on, and with which credential, is your code —
the runner takes both as a small contract (resolve + credential) and your
error types ride its error channel generically.
bun add ai-task-runner-effect claude-code-effect ai @ai-sdk/anthropic @ai-sdk/google @ai-sdk/openai effectThe task table
import { Effect } from "effect"
import { zodToJsonSchema } from "zod-to-json-schema"
import type { TaskTable } from "ai-task-runner-effect"
const TASKS = {
summarize: {
// Validator-agnostic: a JSON Schema for the model, a decode for the answer.
// Zod here — Effect Schema, Valibot or hand-written guards work the same.
output: {
jsonSchema: zodToJsonSchema(Summary),
decode: (raw: unknown) =>
Effect.try({ try: () => Summary.parse(raw), catch: (e) => e }),
},
cliPrompt: (input: { text: string }) => `Summarize:\n${input.text}`,
hostedPrompt: (input: { text: string }) => `Summarize:\n${input.text}`,
hostedInstruction: "Return the summary as JSON.",
allowedTools: [],
},
} as const satisfies TaskTableTwo prompt columns are the deliberate part. The CLI transport runs with tools and may be told to reach for things — a local API, a file — that the hosted transport has no way to act on; a CLI prompt can therefore carry material (a bearer token, a path) that must never be posted to a vendor. A task with nothing to say differently lists the same builder twice, on purpose: nothing can then quietly hand the CLI prompt to a vendor.
The runner
import { Effect, Redacted } from "effect"
import { makeTaskRunner } from "ai-task-runner-effect"
const runner = makeTaskRunner(TASKS, {
// Which provider + model this task runs on — your settings, your errors.
resolve: (task) => readMySettings(task),
// A hosted vendor's API key, or null when none is stored.
credential: (vendor) => readMyKey(vendor), // Effect<Redacted<string> | null>
})
const program = runner.run("summarize", { text: "..." })
// Effect<TaskRunResult<Summary>, YourErrors | transport errors, ClaudeCode>run is fully typed by the table: run("summarize", …) will not typecheck
with another task's input, and the result's output is that task's decoded
type.
The CLI branch requires claude-code-effect's ClaudeCode service in R —
provide ClaudeCodeLive with your ClaudeConfig (binary, timeout, token) and
platform layer once, at your boundary. A token that lives in a database rather
than the environment is that config's effect-form token, resolved per call.
import { Layer } from "effect"
import { BunContext } from "@effect/platform-bun"
import { ClaudeCodeLive, ClaudeConfigLive } from "claude-code-effect"
const AppLive = ClaudeCodeLive.pipe(
Layer.provide(ClaudeConfigLive),
Layer.provide(BunContext.layer),
)
Effect.runPromise(program.pipe(Effect.provide(AppLive)))Errors
Small on purpose. The consumer's resolve/credential failures keep their own
types; the CLI branch fails with claude-code-effect's tags untouched. Only
what this package alone can discover gets a tag:
| Tag | Meaning |
| --- | --- |
| TaskNotRunnableError | The resolved vendor's key was absent at run time — a race guard, not the primary check. Validate pairings at save time in your own code. |
| HostedApiError | The vendor call failed (network, auth, quota). detail is scrubbed of the API key. |
| TaskSchemaError | The vendor answered but the payload failed the task's codec — the model broke its contract. issues is your validator's own failure shape. |
Nothing falls back. A vendor that refuses, or a key that is gone, fails the run: your user chose which vendor sees their data, and a different vendor is not an acceptable recovery.
Testing
Both transports fake at their real seams — no module mocks:
- CLI:
claude-code-effect'sClaudeCodeTest.handler(the SDK's arg assembly and envelope parse run for real against a canned capture). - Hosted: pass a
HostedGeneratefake asmakeTaskRunner's third argument —{ generateHosted: async (args) => payload }— and assert what reached it.
What this package deliberately does not own
- Model catalogues and settings. Which providers a user may pick, which models each serves, what a valid pairing is: application code, checked at save time where your user is still looking.
- Secrets at rest. Keys arrive
Redactedthroughcredentialand are unwrapped once, at the call that spends them. - An injection seam.
makeTaskRunneris a factory, not anEffect.Tag— wrap the runner in your app's own service tag so your app keeps exactly one seam, and two runners over two tables collide on nothing.
MIT
