Integration guide

Guide · v0.0.1

Integrate your app with Bela

This is the guide for wiring Bela into your own web app, standalone — no other context needed. It walks through install, defining entities and tools, creating the agent, handling outcomes in a chat UI, and writing good utterances.

Status. Bela is currently at v0.0.1 (P1 + P2 + P3 + P4 — core skeleton, self-trained neural fallback, dialogue safety, and CLI/packaging): the deterministic matching pipeline (templates + fuzzy entity resolution + date/number extraction) is real and tested; a small self-trained neural intent/slot model can be layered on top as a fallback for paraphrases the templates can't match; handle(text, ctx) carries per-user session state — missing-slot/ambiguous follow-ups, enforced confirm: true gating, roles-based permission checks, and an audit callback — and a real bela CLI (init/train/eval) wraps the training/eval workflow below.

On npm today as bela-ainpm install bela-ai — see Current limitations and the CLI reference's "today vs. in-repo dev" note.

0. The CLI workflow (recommended path)

Most projects don't need to call trainModel/saveModel/loadModel directly — the bela CLI wraps the same functions with a gate check and a flag interface, and is the recommended way to train and evaluate a model:

terminal
bela init ./my-agent
$EDITOR ./my-agent/agent.tools.mjs   # write your real entities/tools
bela train --config ./my-agent/agent.tools.mjs --out ./my-agent/bela-model.json
bela eval  --config ./my-agent/agent.tools.mjs --model ./my-agent/bela-model.json

bela train runs trainModel, checks the result against meetsGate (defaults: intentAcc>=0.9, slotExact>=0.8), and only calls saveModel if the gate passes (or --force is given) — so a config change that tanks model quality can't silently ship a bad artifact. Full flag reference, exit codes, and a worked session: the CLI reference.

The rest of this guide (§7§8) covers the programmatic API (trainModel/saveModel/loadModel/makeFallback from @bela/model directly) — reach for it if you need custom training options, a build-step integration the CLI doesn't cover, or to call createAgent(config, { fallback }) in your app, which the CLI doesn't do for you.

1. Install

Bela isn't published to npm yet. Today, @bela/core is a workspace package you pull in via the monorepo/git, not npm install @bela/core.

If your app lives alongside the Bela repo (or as a git submodule / cloned sibling), add it as a workspace dependency:

your app's package.json
{
  "dependencies": {
    "@bela/core": "workspace:*"
  }
}

Or, until it's published, point at the git repo/commit directly in your lockfile-friendly package manager of choice. The bela CLI (init/train/eval, see §0 above and the CLI reference) is built and tested, but a real npm release of @bela/cli — so npx bela init works outside this monorepo — is still a P5 roadmap item.

2. Define your entities

Entities are the "nouns" your app cares about — rooms, menu items, guests, whatever your tools need to resolve free text against. Each entity has:

agent.tools.ts — entities
import { defineAgent } from "@bela/core"

const config = defineAgent({
  entities: {
    Room: {
      resolve: () => prisma.room.findMany({ select: { id: true, number: true } }),
      match: ["number"],
    },
    MenuItem: {
      resolve: () => prisma.menuItem.findMany(),
      match: ["name", "aliases"],
    },
    Guest: {
      resolve: () => prisma.guest.findMany(),
      match: ["name"],
    },
  },
  tools: [ /* see below */ ],
})

Every row needs an id (string | number) — that's what gets handed to your tool's run handler once a slot resolves.

3. Define your tools

A tool is one command Bela can execute. It has:

agent.tools.ts — tools
import { tool, entity, number, dateRange } from "@bela/core"

const orderFood = tool("orderFood", {
  params: { qty: number({ default: 1 }), item: entity("MenuItem"), room: entity("Room") },
  utterances: [
    "order {qty} {item} for room {room}",
    "order {item} for room {room}",
    "{item} for room {room}",
  ],
  run: ({ item, room, qty }) => api.post("/api/orders", { itemId: item.id, roomId: room.id, qty }),
})

const cancelBooking = tool("cancelBooking", {
  params: { guest: entity("Guest") },
  utterances: ["cancel {guest} booking", "cancel booking for {guest}"],
  confirm: true, // always pauses for an explicit yes/no before running
  run: ({ guest }) => bookings.cancel(guest.id),
})

const closeDay = tool("closeDay", {
  params: {},
  utterances: ["close the day", "close day"],
  roles: ["manager"], // only handle(text, { role: "manager" }) can run this
  run: () => shift.close(),
})

defineAgent validates your config at build time and throws if:

4. Create the agent

app.ts
import { createAgent, defineAgent } from "@bela/core"

const agent = createAgent(defineAgent({ entities, tools }))

await agent.sync() // pulls fresh entity rows via each entity's resolve()

Call agent.sync() on startup and whenever your underlying data changes meaningfully (new rooms, menu changes, etc.) — it's what refreshes the in-memory knowledge base the fuzzy resolver matches against. There's no background scheduler in the engine yet; your app decides when to re-sync.

createAgent(config, opts?) takes:

5. Handle commands

app.ts — one call per message
const outcome = await agent.handle(userText, { userId: "u1", role: "manager" })

handle(text, ctx?) takes an optional HandleContext:

types
export type HandleContext = { userId?: string; role?: string }

handle returns a discriminated union (Outcome, exported from @bela/core) — branch on outcome.type in your chat UI:

the Outcome union
export type Outcome =
  | { type: "executed";  tool: string; args: Record<string, unknown>; result: unknown; reply: string }
  | { type: "need_slot"; tool: string; param: string; reply: string }
  | { type: "ambiguous"; tool: string; param: string; options: EntityRow[]; reply: string }
  | { type: "confirm";   tool: string; args: Record<string, unknown>; reply: string }
  | { type: "cancelled"; tool: string; reply: string }
  | { type: "denied";    tool: string; reply: string }
  | { type: "unknown";   reply: string }
  | { type: "error";     tool: string; reply: string; detail: string }

What to do with each:

outcome.typeWhat happenedWhat your UI does
executedThe tool ran.Show outcome.reply (e.g. "Done — orderFood."). outcome.result is whatever your run handler returned, if you want more detail.
need_slotA required param is still missing/unresolved.Show outcome.reply (e.g. "Which room?") as a follow-up question. Just relay the next raw message from the same userId back into handle() — the session remembers what was already filled in.
ambiguousThe entity text matched more than one row (e.g. two guests named "John").Show outcome.reply, which already lists the candidates as 1) … 2) …. The user can answer with the number or with enough of the name to be unique; either resumes the same session.
confirmThe matched tool has confirm: true.Show outcome.reply (e.g. "Confirm cancelBooking: guest=John Smith? (yes/no)") and wait for the same userId's next message. Nothing has run yet — outcome.args are the resolved args that would run.
cancelledThe user replied "no" (or nahi/nah/cancel/n) to a confirm prompt.Show outcome.reply ("Okay, cancelled."). Nothing ran; an audit entry with outcome: "cancelled" was recorded if you passed an audit callback.
deniedThe tool has a roles list and ctx.role isn't in it.Show outcome.reply ("Sorry, you're not allowed to do that."). An audit entry with outcome: "denied" was recorded.
unknownNothing matched any tool template (and the fallback, if any, also missed).Show outcome.reply verbatim — a "here's what I can do" list built from each tool's first utterance. This is the intended "no chit-chat" fallback.
errorA tool's run handler threw.Show outcome.reply (a safe templated message — your handler's original error never leaks to the user). Log outcome.tool/outcome.detail server-side. An audit entry with outcome: "error" was recorded.

Sessions, follow-up turns, and the numbered-reply format

need_slot, ambiguous, and confirm all set a pending session for that userId — the next call to handle() from the same userId is checked against that pending state before anything else:

6. Writing good utterances

7. Enabling the neural fallback

The deterministic matcher (templates + fuzzy entity resolution) is the whole engine in P1. As of P2, you can train a small neural intent/slot model straight from your own defineAgent(...) config and wire it in as a fallback for the phrasings your templates don't cover:

train.ts — one-time, or whenever your config/data changes
import { defineAgent } from "@bela/core"
import { trainModel, meetsGate, saveModel } from "@bela/model"

const config = defineAgent({ entities, tools })
const { model, metrics } = await trainModel(config, { seed: 42, perTool: 200, epochs: 12 })
if (!meetsGate(metrics)) throw new Error("model below quality gate — check templates/coverage")
saveModel(model, "./bela-model.json")
app.ts — at startup, load the saved model and wire it in
import { defineAgent, createAgent } from "@bela/core"
import { loadModel, makeFallback } from "@bela/model"

const config = defineAgent({ entities, tools })
const model  = loadModel("./bela-model.json")
const agent  = createAgent(config, { fallback: makeFallback(model, 0.6) })

await agent.sync()
await agent.handle("get karahi in room 4") // paraphrase your templates never listed

How it fits together:

8. Roles, the audit log, and teaching Bela with corrections

Roles

Add roles: string[] to a tool (see §3) to restrict who can run it. Pass the caller's role as ctx.role in handle(text, ctx). No role, or a role not in the list, gets a denied outcome instead of execution — the tool never runs and nothing is mutated.

Audit log

Pass audit: (e: AuditEntry) => void to createAgent(config, opts) to record every outcome that isn't a plain question back to the user:

the AuditEntry shape
export type AuditEntry = {
  at: Date; userId: string; input: string; tool: string
  args: Record<string, unknown>
  outcome: "executed" | "error" | "denied" | "cancelled"
  detail?: string // present on "error", the caught error's String()
}

CorrectionStore and retraining on real phrasings

If you've wired in the P2 neural fallback (§7) and a real user's phrasing gets misrouted or falls through to unknown/the wrong tool, capture it and feed it back into the next training run instead of hand-tuning utterances forever:

recording a correction
import { CorrectionStore } from "@bela/model"

const corrections = new CorrectionStore("./corrections.jsonl")

// wherever you learn the right answer (support ticket, manual review, a
// "that's wrong, I meant..." UI action) — record the raw text and the tool
// it should have matched:
corrections.add({ text: "get me karahi for room four please", tool: "orderFood" })
retraining with corrections
import { trainModel, saveModel } from "@bela/model"

const corrections = new CorrectionStore("./corrections.jsonl").load()
const { model, metrics } = await trainModel(config, { seed: 42, perTool: 200, epochs: 12, corrections })
saveModel(model, "./bela-model.json")

Bootstrapping corrections with an LLM (scripts/distill.mjs)

Waiting for real misfires is slow, and a brand-new deployment has none. You can bootstrap the same correction file by asking a bigger model — one you host yourself — how your users would actually phrase each command, then training the tiny model on its answers. The big model is used once, offline; the runtime stays templates + the tiny net, with no LLM in the request path.

terminal
BELA_LLM_URL=http://127.0.0.1:18795 \
  node scripts/distill.mjs --config ./agent.tools.js --out ./corrections.jsonl --n 25
If your config is TypeScript, run it under tsx instead of node.

For every tool the script sends one prompt containing the tool's name, its utterance templates, its parameter names, and a handful of real entity values pulled from your database via collectEntityValues. It asks for N short command-style phrasings with the slots already filled in, requests strict JSON, retries on a parse failure or a short batch, de-dups within and across tools, and appends each surviving line to the output file as a { text, tool } correction via CorrectionStore. It prints a per-tool count so you can see which tools the model struggled with.

FlagDefaultMeaning
--configpath to the module exporting your AgentConfig (required)
--outcorrections .jsonl to append to (required)
--n25phrasings per tool
--url$BELA_LLM_URLOpenAI-compatible base URL; else http://127.0.0.1:18795
--model$BELA_LLM_MODELmodel name to request; else local
--concurrency3parallel tools in flight (capped at 4)
--temperature0.9high on purpose — you want variety, not the single best phrasing
--retries3attempts per tool before giving up
--toolsallcomma-separated subset, for iterating on one tool
--dryoffprint counts without writing the file

Notes:

8b. Optional: thinking with a big LLM

If neither the deterministic templates nor the trained neural fallback (§7) catch a phrasing — an odd paraphrase, a rare edge case — you can layer a third, optional fallback that calls out to a real LLM over any OpenAI-compatible /v1/chat/completions endpoint (a local llama.cpp server, for example):

chaining a neural and an LLM fallback
import { defineAgent, createAgent } from "@bela/core"
import { loadModel, makeFallback, makeLlmFallback, describeTools, chainFallbacks } from "@bela/model"

const config = defineAgent({ entities, tools })
const model  = loadModel("./bela-model.json")
const neural = makeFallback(model, 0.6)
const llm    = makeLlmFallback({
  url: "http://127.0.0.1:8080/v1/chat/completions", // any OpenAI-compatible server
  tools: describeTools(config), // derives name/params/utterances from your config
  timeoutMs: 8000,
})

const agent = createAgent(config, { fallback: chainFallbacks(neural, llm) })

Be honest with yourself about what this buys you:

9. Current limitations (be honest with your users)

FAQ

My app isn't Node (PHP / WordPress / Laravel / Django)?

Not supported yet, but it's an explicit roadmap item: an HTTP sidecar mode where Bela runs as its own small process, tools are declared as REST endpoints (URL + params + auth header) in a JSON/YAML config, and entity resolvers are read-only HTTP endpoints instead of in-process functions. Same engine, HTTP transport instead of direct function calls — no redesign required, just not built yet.

Does it call an LLM API?

No. No external AI API calls, no per-token cost. The core is deterministic matching in TypeScript, and the optional P2 fallback is a small model you train yourself, from scratch, on your own config and data — it runs and trains on your own VPS in seconds on one CPU core, not a hosted LLM.

Can it write to my database directly?

No, and it never will — Bela can only call tools you explicitly register. run handlers are your own functions or API calls; you keep full control of your business rules and data access.

What happens if a command doesn't match anything?

You get an unknown outcome with a reply listing what the agent can do (from each tool's first utterance) — never a guess, never chit-chat.

Is this production-ready?

It's v0.0.1 — the deterministic engine, the neural fallback, dialogue safety (sessions, enforced confirm: true, roles, audit), and the bela CLI (init/train/eval) all work and are tested end-to-end (see fixtures/hotel/ and packages/cli/test/). An npm release and a real production deployment are still ahead (P5).

Start Bela on day one (greenfield apps)

The cheapest moment to integrate Bela is while the app is being built. The pattern that worked in production:

  1. Install at project startbela init next to your first migration. The starter config is your agent.tools.ts seed.
  2. One feature, one tool. Every time you ship a user-facing action (an API route or server action), add its Bela tool in the same pull request — the tool is usually 15 lines: params, five utterances (write them in the languages your users actually type), and a run that calls the function you just wrote. While the feature is fresh in your head, the utterances write themselves.
  3. Mirror your permission table. If your app has a roles/permissions module, derive each tool's roles from it (roles: rolesFor("order:create")) instead of hand-writing lists — Bela then can never allow what the app forbids, and stays in sync forever. Rule of thumb: whatever a user can do in the app with their role, they can do through Bela — no more, no less.
  4. Retrain in CI. bela train after schema/config changes; the accuracy gate fails the build if the config got confusing. Keep a golden.mjs of real user phrases and replay it (dry-run) before deploys.
  5. Ship the memory file empty. bela-memory.md belongs to the customer. The phrases and aliases they add are the difference between a demo and their agent.