@moku-labs/ai
v0.15.2
Published
A build system for AI-generated assets — declarative build files in, artifacts out. Any task × any provider × any account pool. Resumable always, incremental by default, never loses a byte of progress.
Downloads
3,825
Readme
@moku-labs/ai
A build system for AI-generated assets — declarative build files in, artifacts out.
Any task × any provider × any account pool. You declare what should exist in a
*.moku.yaml build file; the runner drives whichever provider implements the task,
gates every dollar through an atomic budget check, and journals every state transition
to crash-durable SQLite. It is not an SDK wrapper and not a general job queue — it is a
build system: resumable always, incremental by default, never loses a byte of progress.
Built on @moku-labs/core (a Layer-2 framework in
the three-layer Moku model).
Install · Quick start · Plugins · CLI · Configuration · Events · Docs
Why @moku-labs/ai
kill -9is a supported workflow. Every run/item/attempt transition is a committed SQLite write under WAL +synchronous=FULLinsideBEGIN IMMEDIATE— a crash at any instant loses at most mid-flight work, never recorded progress.moku runagain and the run resumes where it stopped.- Budget-gated by construction. One atomic transaction checks projected spend
(
done actuals + dispatching estimates + this item) against--max-costand dedups the item before anything is billed. The guarantee isspend ≤ done items + items dispatching at kill. - Any task × any provider. Task plugins own capability contracts
(
voiceover,translate,prompt-gen,image,video,music,sfx,sprite,asset); provider plugins (elevenlabs,openai,codex,claude,fal,apimodels,ark) register handlers with a dumb registry. Neither imports the other — consumer apps add both without touching the framework. - Incremental by default. Every item has an artifact key (
sha256of task, provider, input, params, and the keys of everything it references). A done artifact is reused by any later run for $0, so re-running a build re-bills nothing that is already done — and changing one keyframe re-renders only that shot. - Long provider jobs survive crashes. Video handlers
submitandpoll; the provider job id is journaled before anything waits on it, so a crash, Ctrl-C or timeout is resumed by polling the same job, never by paying for it twice. - A build system, not an SDK wrapper. No streaming chat helpers, no agent loops — build files in, integrity-verified artifacts and a durable cost ledger out.
Install
bun add @moku-labs/ai @moku-labs/core @moku-labs/common[!NOTE] Status:
0.x— early.@moku-labs/core(^1.7.1) and@moku-labs/common(^0.3.4) are peer dependencies: install them next to the package, one copy per project. The other runtime dependencies (better-sqlite3,openai,sharp,yaml,zod) install with the package. On Bun the journal uses the built-inbun:sqlitedriver instead ofbetter-sqlite3. Providers read API keys from the environment at request time — exportELEVENLABS_API_KEY/OPENAI_API_KEY/FAL_KEY/APIMODELS_API_KEY/ARK_API_KEY(plusARK_ACCESS_KEY/ARK_SECRET_KEYfor Ark assets), or put them in a.env.localfile in the working directory (the shell wins over the file), before executing (estimates and validation never need a key).codexandclaudefind their CLI onPATH;NO_COLORturns on plain CLI output.
Quick start
Declare a build file (or generate one: moku compose "narrate and translate a greeting"):
# hello.moku.yaml
version: 1
name: hello
items:
- task: voiceover
input:
text: "Hello, world!"
voice: "21m00Tcm4TlvDq8ikWAM"
- task: translate
provider: openai
input:
text: "Hello, world!"
targetLang: esRun it programmatically:
import { createApp } from "@moku-labs/ai";
const app = createApp({});
await app.start();
// Estimate first, then run with a budget ceiling.
const { totalUsd } = await app.runner.estimate({ files: "hello.moku.yaml" });
const result = await app.runner.run({ files: "hello.moku.yaml", maxCostUsd: totalUsd * 1.2 });
console.log(result.status, result.totals); // "done", { done: 2, spendUsd: ..., ... }
await app.stop();Or from the shell — the package ships the moku bin:
moku new hello # scaffold hello.moku.yaml + its editor JSON Schema
moku validate # compile every matched build file through the zod IR
moku estimate # per-task/provider cost breakdown, no key needed
moku run --max-cost 5 # execute; Ctrl-C is a clean pause, `moku run` resumesHow it works
flowchart LR
U["You<br/>*.moku.yaml"] --> BF["buildfile<br/>zod IR"]
BF --> RN["runner<br/>plan → gate → execute"]
RN --> LM["limits<br/>lane admission"]
RN --> PR["provider handler<br/>elevenlabs / openai"]
PR --> ST["store<br/>CAS bytes"]
RN --> JR["journal<br/>SQLite WAL ledger"]
ST --> A["artifacts +<br/>durable cost ledger"]
JR --> A
classDef u fill:#0b7285,stroke:#08525f,color:#fff;
classDef m fill:#1864ab,stroke:#0d3d6e,color:#fff;
class U,A u
class BF,RN,LM,PR,ST,JR mEvery item of every build file moves through one uniform pipeline: plan (compile,
resolve provider, compute planning key, insert as queued) → admit
(limits.acquire("{task}/{provider}/default")) → gate (atomic budget + dedup +
queued → dispatching in one transaction) → execute (registry-resolved handler) →
persist (store.put the bytes, then journal.commitDone the metadata) →
report. Retryable failures (5xx / 429 / timeout / network) re-queue with jittered
exponential backoff; other 4xx is terminal failed; content policy is terminal
flagged, never re-queued.
The journal is the contract
The journal holds metadata only — statuses, costs, hashes, error classes; the store holds the bytes those hashes name. An artifact exists when both halves do, bytes first. That split is the redaction boundary (prompts and payloads are structurally impossible to journal) and the durability guarantee in one design: never lose a byte, never bill one twice.
Plugins
Three core plugins are injected on every plugin's ctx (plus ctx.log / ctx.env
inherited from @moku-labs/common); twenty-one regular
plugins mount their APIs on the app by name (app.runner, app.cli, …).
| Plugin | Tier | Kind | Responsibility |
|---|---|---|---|
| journal | Complex | core (ctx.journal) | SQLite WAL ledger — runs/items/attempts state machine, the atomic budget + dedup gate, crash durability. |
| store | Standard | core (ctx.store) | Content-addressed artifact store — fsync-durable writes, integrity-verified reads, gc. |
| limits | Standard | core (ctx.limits) | Per-lane token bucket + concurrency gate + circuit breaker for all provider traffic. |
| registry | Nano | regular (app.registry) | The task → provider → handler map that keeps tasks and providers decoupled. |
| buildfile | Standard | regular (app.buildfile) | YAML / defineBuild() front-end — one zod schema, BuildSpec IR, JSON Schema, starter template. |
| runner | Complex | regular (app.runner) | The durable orchestrator — run/resume/estimate/status/events(); owns all bus events. |
| voiceover | Standard | regular (app.voiceover) | Owns the "voiceover" task contract + one-off generate/estimate/providers facade. |
| translate | Standard | regular (app.translate) | Owns the "translate" task contract + one-off facade. |
| promptGen | Standard | regular (app.promptGen) | Owns the "prompt-gen" task contract + one-off facade (backs compose); fallback chain to the next provider when one is unavailable. Tool calling: messages, tools, toolChoice, cacheSystem, typed usage (served by fal), and runToolLoop, a tool loop the caller journals, with a rolling prompt-cache breakpoint on the conversation (cache). |
| elevenlabs | Complex | regular (app.elevenlabs) | ElevenLabs provider — registers ("voiceover", "elevenlabs") and ("sfx", "elevenlabs"); price table, retry-taxonomy errors. |
| openai | Complex | regular (app.openai) | OpenAI provider — registers voiceover, translate, and prompt-gen handlers via the official SDK. |
| compose | Standard | regular (app.compose) | Natural language → validated build file, with an LLM repair loop that can never emit an invalid spec. |
| image | Standard | regular (app.image) | Owns the "image" task contract + one-off facade. |
| video | Standard | regular (app.video) | Owns the "video" task contract (execute or submit + poll) + one-off facade. |
| music | Standard | regular (app.music) | Owns the "music" task contract (execute or submit + poll) + one-off facade. MusicRequest.model is required. |
| sfx | Standard | regular (app.sfx) | Owns the "sfx" task contract (execute only, always mp3) + one-off facade. SfxRequest.model is required. |
| sprite | Standard | regular (app.sprite) | Owns the "sprite" task contract — cut a $ref'd image into a trimmed, transparent PNG — + one-off facade and the pixel step processSprite. |
| asset | Standard | regular (app.asset) | Owns the "asset" task contract — register a portrait with a provider, get an AssetRecord that video items $ref — + one-off facade. |
| codex | Complex | regular (app.codex) | Image and prompt-gen provider over the local Codex CLI (codex exec), plan-billed. |
| claude | Complex | regular (app.claude) | Prompt-gen provider over the local Claude Code CLI (claude -p), plan-billed. |
| fal | Complex | regular (app.fal) | Six tasks over one fal key, client, upload cache and price table. Video: Seedance, MiniMax H3, Kling, Wan, Veo, Vidu, Gemini Omni. Image: Nano Banana Pro, Seedream 4.5, GPT Image 2.5. Prompt-gen: fal's OpenRouter router. Music: ElevenLabs Music v2.5, Stable Audio 2.5. Sfx: ElevenLabs SFX v2. Sprite: BiRefNet matte. app.fal.models(task) lists each task's models with prices. app.fal.upload(file, opts?) uploads a local file to storage and returns { url }. |
| apimodels | Complex | regular (app.apimodels) | Video provider over apimodels.app: Seedance 2.5 and 2.0 official, which accept real faces; optional asset:// registration via item params.assets, cached in the journal. |
| ark | Complex | regular (app.ark) | Seedance 2.0, 2.0 fast, 2.0 mini and 2.5 straight from ByteDance — BytePlus ModelArk (intl) or Volcengine Ark (cn); video, image (Seedream 5.0 lite, text- and image-to-image) and asset providers, 2.5 draft → 1080p final, trusted Seedream faces and asset:// portraits, per-token prices. app.ark.listAssetGroups() and app.ark.listAssets(filter?) list the asset library; app.ark.deleteAsset(assetId) and app.ark.deleteAssetGroup(groupId) delete entries. |
| cli | Complex | regular (app.cli) | The moku command surface — seven commands, branded rendering, a ratified exit-code contract. |
The moku CLI
The package bin ("moku") is a plain Layer-3 consumer:
load the project config → createApp(options) → start() →
app.cli.dispatch(argv) → stop() → exit.
| Command | What it does |
|---|---|
| moku new [name] | Write a starter build file + its JSON Schema (editor autocomplete via modeline). |
| moku validate [glob] | Compile every matched build file through the zod IR; offline, no providers needed. |
| moku estimate [glob] | Per-task/provider cost breakdown + total — the same math the budget gate uses. |
| moku run [glob] [--max-cost <usd>] [--dry-run] [--out <dir>] [--flat] | Execute matched build files with live progress; SIGINT drains to a clean pause. Done artifacts are exported to <out>/<build>/<label>.<ext> (default out/), or <out>/<label>.<ext> with --flat. |
| moku export [runId] [--out <dir>] [--flat] | Copy a run's done artifacts (default: the newest run) to named files. --flat drops the <build>/ folder; a label already written by this export is skipped and listed. |
| moku status [runId] [--follow] | Snapshot (or 1s-poll) a run's totals — safe from a second process. |
| moku compose "<prompt>" [--emit build\|script] [--out <path>] | Generate a build file from natural language. |
Exit codes (Cli.EXIT_CODES): 0 ok · 1 failure · 2 validation · 3 usage ·
4 paused (SIGINT drain) · 5 budget stop.
Project config (moku.config.ts)
Every command reads one project config. The bin looks in the working directory for
moku.config.ts, .mts, .js, .mjs, in that order; the first file wins. --config <path>
(or --config=<path>) on any command wins over the search; the path resolves against the
working directory, and the flag is removed before the command sees argv. No file: today's defaults.
// moku.config.ts
import { defineConfig } from "@moku-labs/ai";
import { myProvider } from "./plugins/my-provider";
export default defineConfig({
plugins: [myProvider],
pluginConfigs: { ark: { region: "cn" }, fal: { upload: "data-uri" } }
});The default export goes to createApp as is. Only plugins and pluginConfigs are allowed.
defineConfig returns its argument; it types pluginConfigs, also for the custom plugins in
plugins, so an unknown key or a wrong value is an editor error. Core plugins (journal,
store, limits) are typed there too. Node 24 strips the types of a .ts file; Bun loads it.
A config that does not load prints [ai] Could not load <path>. and the reason, and exits 3
before any app is created: a --config file that does not exist, a module that throws, or a
default export that is not an object. --config without a path also exits 3.
moku run --config configs/cn.ts --max-cost 5Usage
Create an app and configure plugins
import { createApp } from "@moku-labs/ai";
const app = createApp({
pluginConfigs: {
voiceover: { defaultProvider: "elevenlabs", defaultFormat: "mp3" },
openai: {
apiKeyEnv: "OPENAI_API_KEY",
models: { tts: "gpt-4o-mini-tts", chat: "gpt-4o" },
timeoutMs: 60_000,
priceOverrides: {}
},
runner: { maxAttempts: 5 }
}
});
await app.start();Plugin APIs mount on the app by plugin name: app.runner.run(...),
app.voiceover.generate(...), app.buildfile.compile(...), app.cli.dispatch(...).
Core APIs are there too: app.limits.snapshot(lane).
[!IMPORTANT]
createApp'spluginConfigsis typed to regular plugins only. The core plugins (journal,store,limits) are configured wherecreateCoreis called — at M0 their framework defaults (.moku/journal.db,.moku/store, 60 rpm / 4 concurrent per lane) are fixed for consumer apps.
One-off facades vs. the durable path
// One-off: direct, NOT journaled — for scripts, tests, experiments.
const speech = await app.voiceover.generate({ text: "Hi!", voice: "21m00Tcm4TlvDq8ikWAM" });
const spanish = await app.translate.generate({ text: "Hi!", targetLang: "es" });
// Durable: journaled, budget-gated, resumable — for anything worth keeping.
const result = await app.runner.run({ files: "**/*.moku.yaml", maxCostUsd: 25 });
if (result.status === "paused") await app.runner.resume({ runId: result.runId });Add your own provider (Layer 3)
import { createApp, createPlugin, registryPlugin } from "@moku-labs/ai";
import type { Voiceover } from "@moku-labs/ai";
const handler: Voiceover.VoiceoverHandler = {
estimate: request => ({ usd: request.text.length * 0.00003 }),
execute: async request => ({
audio: await synthesize(request.text, request.voice),
mimeType: "audio/mpeg",
costUsd: 0.001
})
};
const acmeTtsPlugin = createPlugin("acmeTts", {
depends: [registryPlugin],
onInit: ctx => {
ctx.require(registryPlugin).register("voiceover", "acme", handler);
}
});
const app = createApp({ plugins: [acmeTtsPlugin] });The same shape registers a whole new task: pick a task key, define an
estimate/execute handler contract, register it — build files can name it
immediately (task: my-task), and the runner executes it through the same durable
pipeline. The runner hands every handler the item's input spread flat plus
params — exactly the task contract's request.
A handler for a long provider job exposes submit + poll instead of (or next to)
execute. The runner journals the returned job id before it waits, polls every
runner.pollIntervalMs, and after a crash or pause polls that job again instead of
re-submitting it. Throw errors with status or kind: "timeout" | "network" |
"content-policy" to have them retried or flagged; an error without a hint is a
programming error and fails the item after one attempt. kind: "invalid-request" |
"local-failure" fails the item after one attempt under that class (the handler refused
the request, or its own machine failed). Set publicMessage to a text that is safe to
show (no keys, no prompts) and item:failed or item:flagged carries it as message.
Images, video and references
Items reference each other with $ref (another item's artifact, by id) and local
files with $file (relative to the build file). The runner runs references first
and hands the handler a { path, mimeType, hash } file:
version: 1
name: ep01
items:
- id: s01.key
task: image
provider: codex
input: { prompt: "patisserie counter at night, warm lamps", aspect: "9:16",
refs: [{ $file: refs/akari.png }] }
- id: s01.h3
task: video
provider: fal
input: { model: minimax-h3, prompt: "slow push-in", image: { $ref: s01.key }, seconds: 5 }
- id: s01.score
task: music
provider: fal
input: { model: elevenlabs-music-v2.5, prompt: "tense synth pulse", lengthMs: 30000 }moku run ep01.moku.yaml --max-cost 10 renders the keyframe, then the clip, and
writes out/ep01/s01.key.png, out/ep01/s01.h3.mp4 and out/ep01/s01.score.mp3.
The fal models and their prices are listed in the fal README;
a model with no price fails the estimate instead of counting as $0.
music items need model: the runner hashes the input as written, so there is no
provider default. The same request works one-off:
app.fal.models("music"); // => [{ id: "elevenlabs-music-v2.5", price: { usd: 0.8, per: "minute" } }, ...]
const track = await app.music.generate({
prompt: "tense synth pulse", model: "elevenlabs-music-v2.5", lengthMs: 60_000
});
await Bun.write("teaser.mp3", track.audio);Game assets: sound effects and sprites
sfx items make a short mp3 from a prompt. model is required, as for music:
- id: coin-pickup
task: sfx
provider: elevenlabs
input: { model: eleven_text_to_sound_v2, prompt: "coin pickup, bright 8-bit chime", durationMs: 600 }A sprite takes two items: an image item makes the raw picture, and a sprite item
$refs it, removes the background, trims, pads and resizes it into a transparent PNG.
An id may end with a nine-slice hint {nine=l,t,r,b}; it stays in the file name:
- id: button-raw
task: image
provider: fal
input: { model: nano-banana-pro, prompt: "wooden game UI button, flat colour background", aspect: "1:1" }
- id: "button{nine=12,12,12,12}"
task: sprite
provider: fal
input: { source: { $ref: button-raw }, model: birefnet, size: { width: 128, height: 64 }, padding: 2 }Export writes out/<name>/coin-pickup.mp3 and out/<name>/button{nine=12,12,12,12}.png. Changing the sprite
options re-cuts the stored image and never regenerates it.
Faces: Seedream first, or register a portrait
Seedance on Ark refuses a real face in an image from outside. It trusts a face that
Seedream 5.0 lite made on the same account, as long as the bytes are unchanged. So make
the keyframe with ark, and $ref it. A Seedance 2.5 draft can then become a 1080p final:
items:
- id: key-01
task: image
provider: ark
input: { prompt: "Vertical 9:16 photo. Close-up, Akari lifts a cake box lid", aspect: "9:16" }
- id: draft-01
task: video
provider: ark
input: { model: dreamina-seedance-2-5-260628, prompt: "Akari lifts the lid, smiles",
image: { $ref: key-01 }, seconds: 5 }
params: { draft: true }
- id: final-01
task: video
provider: ark
input: { model: dreamina-seedance-2-5-260628, fromDraft: { $ref: draft-01 } }The draft is 480p and cheap. The final re-renders that draft at 1080p, with the same motion, face and audio. A draft can be finalized for 7 days.
For a real person, register the portrait instead. An asset item registers it once,
in your own Ark account; the video items $ref it and Ark gets asset://<id>:
items:
- id: face-mira
task: asset
provider: ark
input: { image: { $file: faces/mira.png }, url: "https://cdn.example/faces/mira.png" }
- id: clip-01
task: video
provider: ark
input: { model: dreamina-seedance-2-5-260628, prompt: "image 1 walks into the rain",
refs: [{ $ref: face-mira }], seconds: 10 }The asset is an artifact like any other: registered once, reused by every later run. A refused portrait flags the asset item, and the clips that use it are never dispatched. There is no fallback to the raw photo. Setup (entitlement, authorization letter, group id) and the model table are in the ark README.
Configuration
All configuration is per-plugin (the global Config is empty by ratified decision).
Defaults below are the shipped values; see each plugin's README for full semantics.
| Plugin | Option | Type | Default |
|---|---|---|---|
| journal (core) | path | string | ".moku/journal.db" |
| | checkpointIntervalMs | number | 30_000 |
| | busyTimeoutMs | number | 5000 |
| store (core) | dir | string | ".moku/store" |
| | algo | "sha256" | "sha256" |
| limits (core) | defaults | LaneConfig | { rpm: 60, concurrency: 4, breakerThreshold: 5, breakerCooldownMs: 30_000 } |
| | lanes | Record<string, Partial<LaneConfig>> | {} |
| registry | — | — | no config |
| buildfile | defaultGlob | string | "**/*.moku.yaml" |
| | schemaPath | string | ".moku/build.schema.json" |
| runner | maxAttempts | number | 3 |
| | retryBaseMs | number | 1000 |
| | eventBufferSize | number | 10_000 |
| | pollIntervalMs | number | 5000 |
| | jobTimeoutMs | number | 1_800_000 |
| | maxActiveRuns | number | 1 |
| voiceover | defaultProvider | string | "elevenlabs" |
| | defaultFormat | "mp3" \| "wav" \| "ogg" | "mp3" |
| translate | defaultProvider | string | "openai" |
| promptGen | defaultProvider | string | "openai" |
| | fallback | string[] | [] |
| elevenlabs | apiKeyEnv | string | "ELEVENLABS_API_KEY" |
| | baseUrl | string | "https://api.elevenlabs.io" |
| | defaultModel | string | "eleven_multilingual_v2" |
| | timeoutMs | number | 60_000 |
| | priceOverrides | Record<string, number> | {} |
| openai | apiKeyEnv | string | "OPENAI_API_KEY" |
| | baseUrl | string? | undefined (SDK default) |
| | models | { tts: string; chat: string } | { tts: "gpt-4o-mini-tts", chat: "gpt-4o-mini" } |
| | timeoutMs | number | 60_000 |
| | priceOverrides | Record<string, { inputPerM?; outputPerM?; ttsPerMChars? }> | {} |
| image | defaultProvider | string | "codex" |
| video | defaultProvider | string | "fal" |
| | pollIntervalMs | number | 5000 |
| music | defaultProvider | string | "fal" |
| | pollIntervalMs | number | 5000 |
| sfx | defaultProvider | string | "elevenlabs" |
| sprite | defaultProvider | string | "fal" |
| asset | defaultProvider | string | "ark" |
| | pollIntervalMs | number | 3000 |
| codex | bin | string | "codex" |
| | model | string | "gpt-6-astra" |
| | reasoningEffort | string | "low" |
| | timeoutMs | number | 600_000 |
| | workDir | string | ".moku/tmp" ("" = OS temp dir) |
| | priceOverrides | Record<string, number> | {} |
| | textModel | string | "" (codex default) |
| | modelMap | Record<string, string> | {} |
| claude | bin | string | "claude" |
| | textModel | string | "" (CLI default) |
| | modelMap | Record<string, string> | {} |
| | timeoutMs | number | 600_000 |
| | workDir | string | "" (OS temp dir) |
| fal | apiKeyEnv | string | "FAL_KEY" |
| | queueUrl | string | "https://queue.fal.run" |
| | uploadUrl | string | fal storage initiate URL |
| | upload | "storage" \| "data-uri" | "storage" |
| | timeoutMs | number | 60_000 |
| | priceOverrides | Record<string, number> | {} (video <alias>; image:<alias>, music:<alias>, sfx:<alias>, sprite:<alias>, llm:<id>#in / #out) |
| | runUrl | string | "https://fal.run" |
| | imageDefaultModel | string | "gpt-image-2.5" |
| | llmDefaultModel | string | "anthropic/claude-opus-5.5" |
| | pollIntervalMs | number | 2000 |
| | jobTimeoutMs | number | 900_000 |
| | requestLog | string | "" (off) |
| apimodels | apiKeyEnv | string | "APIMODELS_API_KEY" |
| | baseUrl | string | "https://api.apimodels.app/v1" |
| | assetGroup | string | "moku-ai" |
| | timeoutMs | number | 60_000 |
| | priceOverrides | Record<string, number> | {} |
| ark | region | "intl" \| "cn" | "intl" |
| | apiKeyEnv · accessKeyEnv · secretKeyEnv | string | "ARK_API_KEY" · "ARK_ACCESS_KEY" · "ARK_SECRET_KEY" |
| | baseUrl · controlUrl | string \| null | null (the region's URLs) |
| | groupId | string \| null | null (find the oldest exact group name match, or create and log one if absent; a configured id applies only to config.groupName) |
| | groupName | string | "moku-ai" (used when the request leaves out groupName) |
| | timeoutMs | number | 60_000 |
| | downloadTimeoutMs | number | 300_000 (one clip or image download) |
| | priceOverrides | Record<string, number> | {} (USD per 1M output tokens) |
| | cnyPerUsd | number | 7.1 |
| compose | provider | string | "openai" |
| | maxRepairAttempts | number | 2 |
| cli | plain | boolean | false (auto-true when !TTY or NO_COLOR) |
Events
The runner is the only bus-event declarer at M0. Per-item detail deliberately never
touches the plugin bus (a million items would serialize on sequential-await hook
dispatch) — it flows through app.runner.events(), a backpressured AsyncIterable.
Bus events — subscribe from a plugin that declares depends: [runnerPlugin] and a
hooks map:
| Event | Payload | When |
|---|---|---|
| run:progress | { runId, total, done, failed, flagged, spendUsd } | Coalesced progress, ≤1 per 500ms |
| run:done | { runId, totals: RunTotals } | Run completed |
| run:failed | { runId, error: string } | Unrecoverable run error |
| run:budget-stop | { runId, spendUsd, maxCostUsd } | Budget ceiling reached, run drained |
| run:paused | { runId, drained } | Clean pause (abort) completed |
import { createApp, createPlugin, runnerPlugin } from "@moku-labs/ai";
const reporterPlugin = createPlugin("reporter", {
depends: [runnerPlugin],
hooks: ctx => ({
"run:progress": payload => ctx.log.info("progress", payload),
"run:done": payload => ctx.log.info("run complete", { runId: payload.runId })
})
});
const app = createApp({ plugins: [reporterPlugin] });Stream records (app.runner.events(), discriminated on type): item:queued,
item:dispatching, item:done, item:retry, item:failed, item:flagged,
overflow, progress, and a terminal record that is always delivered last.
Every record carries its runId.
Several runs at once
runner.maxActiveRuns (default 1) lets one process drive several run() /
resume() calls at the same time. Above the cap, run() throws
[ai] A run is already active. Each run keeps its own abort signal, budget, totals
and status. Limits lanes stay global, so two runs share one lane's concurrency and
rpm. An item with the same artifact key in two runs reaches the provider once: the
second run waits and records the first one's result at cost 0. When the first one
ran out of retries on a 5xx, 429, network or timeout error, the second tries itself.
app.stop() pauses the active runs and waits for them; in-flight jobs stay
adoptable by a later resume().
const app = createApp({ pluginConfigs: { runner: { maxActiveRuns: 10 } } });
await app.runner.run(
{ files: "ep01/*.moku.yaml" },
{ onStart: runId => follow(app.runner.events({ runId })) }
);events({ runId }) follows one run. events() follows every run until none is active.
Architecture
flowchart LR
subgraph CORE["core plugins (src/config.ts), injected as ctx.*"]
LOG["log"]
ENV["env"]
J["journal"]
S["store"]
L["limits"]
end
REG["registry"]
BF["buildfile"]
RN["runner"] --> REG
RN --> BF
subgraph TASKS["task plugins"]
VO["voiceover"]
TR["translate"]
PG["promptGen"]
IM["image"]
VI["video"]
MU["music"]
AS["asset"]
end
subgraph PROV["provider plugins"]
EL["elevenlabs"]
OA["openai"]
CX["codex"]
CL["claude"]
FAL["fal"]
AM["apimodels"]
ARK["ark"]
end
TASKS --> REG
PROV --> REG
CO["compose"] --> BF
CO --> PG
CLI["cli"] --> RN
CLI --> BF
CLI --> CO
classDef core fill:#0b7285,stroke:#08525f,color:#fff;
classDef reg fill:#1864ab,stroke:#0d3d6e,color:#fff;
class LOG,ENV,J,S,L core
class REG,BF,RN,VO,TR,PG,IM,VI,MU,AS,EL,OA,CX,CL,FAL,AM,ARK,CO,CLI regAn arrow from a group means every plugin in it has that depends edge: each task and
provider plugin depends on registry only.
The five core plugins come first, from src/config.ts: log → env → journal → store →
limits. They have no depends. The 19 regular plugins follow in src/index.ts, every
plugin after its dependencies:
registry → buildfile → runner → voiceover → translate → promptGen → image → video →
music → asset → elevenlabs → openai → codex → claude → fal → apimodels → ark → compose →
cli. Provider plugins register their handlers in onInit, so by
the time app.start() resolves, every task facade sees its providers — and the
first-registered provider is each task's implicit default.
The factory chain is the standard three-layer Moku shape: src/config.ts builds
coreConfig (createCoreConfig("ai", …) with the five core plugins), src/index.ts
assembles the framework (createCore) and exports createApp + createPlugin for
Layer-3 consumers, plus the helpers defineBuild, defineConfig (with the ProjectConfig type), ASSET_MIME, encodeAssetRecord,
parseAssetRecord, PromptGenUnavailableError, isPromptGenUnavailable,
ToolArgumentsError and runToolLoop.
Development
- Plugins live in
src/plugins/<name>/—index.ts(definition),types.ts,api.ts, plus colocated__tests__/unit/and__tests__/integration/. Roottests/is for framework-level integration only. - 2858 tests in 195 files across unit + integration projects, 90% coverage threshold.
- Follow the family conventions: branded CLI output via
@moku-labs/common/cli,ctx.log(neverconsole.*),ctx.env(neverprocess.env).
Scripts
bun run build # build with tsdown
bun run validate # publint + arethetypeswrong (node16 profile)
bun run lint # biome check + eslint
bun run lint:fix # auto-fix lint issues
bun run format # format with biome
bun run test # all tests (vitest)
bun run test:unit # unit project only
bun run test:integration # integration project only
bun run test:coverage # unit + integration with coverageRequirements
- Node
>= 24and Bun>= 1.3.14— usebunexclusively (never npm/yarn/pnpm). - TypeScript in strict mode, with
exactOptionalPropertyTypesandnoUncheckedIndexedAccess. @moku-labs/core— the micro-kernel this framework composes on.@moku-labs/common— supplies thelog/envcore plugins and the branded CLI renderer.
Docs
- Per-plugin references (configuration, full API, integration notes): journal · store · limits · registry · buildfile · runner · voiceover · translate · promptGen · image · video · music · sfx · sprite · elevenlabs · openai · codex · claude · fal · apimodels · asset · ark · compose · cli
- LLM-oriented docs:
llms.txt(concise) andllms-full.txt(complete type-level reference). - Moku Core specification.
