npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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).

npm types node for @moku-labs/core license: MIT

Install · Quick start · Plugins · CLI · Configuration · Events · Docs


Why @moku-labs/ai

  • kill -9 is a supported workflow. Every run/item/attempt transition is a committed SQLite write under WAL + synchronous=FULL inside BEGIN IMMEDIATE — a crash at any instant loses at most mid-flight work, never recorded progress. moku run again 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-cost and dedups the item before anything is billed. The guarantee is spend ≤ 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 (sha256 of 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 submit and poll; 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-in bun:sqlite driver instead of better-sqlite3. Providers read API keys from the environment at request time — export ELEVENLABS_API_KEY / OPENAI_API_KEY / FAL_KEY / APIMODELS_API_KEY / ARK_API_KEY (plus ARK_ACCESS_KEY / ARK_SECRET_KEY for Ark assets), or put them in a .env.local file in the working directory (the shell wins over the file), before executing (estimates and validation never need a key). codex and claude find their CLI on PATH; NO_COLOR turns 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: es

Run 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` resumes

How 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 m

Every 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 5

Usage

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's pluginConfigs is typed to regular plugins only. The core plugins (journal, store, limits) are configured where createCore is 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 reg

An 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/. Root tests/ 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 (never console.*), ctx.env (never process.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 coverage

Requirements

  • Node >= 24 and Bun >= 1.3.14 — use bun exclusively (never npm/yarn/pnpm).
  • TypeScript in strict mode, with exactOptionalPropertyTypes and noUncheckedIndexedAccess.
  • @moku-labs/core — the micro-kernel this framework composes on.
  • @moku-labs/common — supplies the log/env core plugins and the branded CLI renderer.

Docs

License

MIT © moku-labs