@aman_asmuei/niato
v1.3.0
Published
Intent-routing agent on the Claude Agent SDK. Niato — "intention", from the Malay/Indonesian niat — declares before it acts.
Maintainers
Readme
Niato
An intent-routing agent built on the Claude Agent SDK.
Niato — derived from niat (Malay/Indonesian for "intention", from the Arabic root نِيَّة). The formal declaration of intent before an act.
A small, fast classifier states the user's intent, an Opus orchestrator declares a plan, the right specialist subagent — drawn from a pluggable Domain Pack — carries it out, and guardrails declare what's about to happen before any tool runs. Classify, plan, gate, act — every meaningful action is preceded by a stated intent.
user input
│
▼
┌─────────────────┐
│ classifier │ Sonnet 4.6 — { intent, domain, confidence }
└────────┬────────┘
▼
┌─────────────────┐
│ orchestrator │ Opus 4.7 — declares plan, dispatches via Agent tool
└────────┬────────┘
▼
┌─────────────────┐
│ specialist(s) │ Sonnet 4.6 — minimal tool allowlist per role
└────────┬────────┘
▼
response (+ TurnRecord: classification, tokens, cost, guardrails)Table of contents
- Quick start — install + first turn in 3 commands
- Use the TUI — terminal companion app
- Embed in your code —
createNiato({ ... }) - How it works — the four declarations
- Authentication — API key vs. Claude subscription
- Domain packs — what ships, how to add your own
- Reference — tracing, guardrails, persona, eval baselines
- Development — clone, test, contribute
- Roadmap & status
Quick start
Requires Node 20.6+. Three commands to your first turn:
# 1. Install
npm i -g @aman_asmuei/niato
# 2. Authenticate (pick one)
niato login # Claude subscription (browser OAuth, recommended for laptops)
# — or —
niato setup-token # long-lived token, then `export CLAUDE_CODE_OAUTH_TOKEN=...` (CI / headless)
# — or —
export ANTHROPIC_API_KEY=sk-ant-... # developer API path (per-token billing)
# 3. Run
niatoThat's it. The TUI walks you through companion setup on first run — no shell env vars, no config files to edit by hand.
Prefer not to install globally?
npx @aman_asmuei/niatoworks the same way.Which auth path? Laptop / personal:
niato login. CI / headless / containers:niato setup-token. Production / multi-user:ANTHROPIC_API_KEY. See Authentication for the full table and ToS notes.
Use the TUI
After install, niato (or niato tui) launches a polished Ink terminal app:
- Launcher — New session · Resume last · Settings · About
- Mode pick — Casual (warm, minimal observability) · Dev (full per-turn trace)
- Always-visible footer — classify/dispatch ticks, latency, cost
- Sessions persisted as JSONL at
~/.niato/sessions/{id}.jsonl(last 50 retained)
niato # launcher
niato login # OAuth subscription auth (wraps `claude /login`)
niato chat # legacy multi-turn REPL (kept for back-compat)
niato --help # subcommandsFirst-run flow (all in-app — no shell setup needed):
- Pick auth — Claude subscription or API key.
- If API key: paste it in the in-app prompt. Saved to
~/.niato/auth.json(chmod 600). - Companion setup — name, voice archetype, optional preferences. Saved to
~/.niato/companion.json.
Default packs. The TUI ships with the Generic pack only (Support and Dev Tools are demo packs — Support uses a stub MCP). To enable them:
export NIATO_PACKS=support,dev_toolsEmbed in your code
The package exports createNiato(...) as the entry-point factory. Minimal example:
import { createNiato, genericPack } from "@aman_asmuei/niato";
const niato = createNiato({ packs: [genericPack] });
const turn = await niato.run("Explain how DNS works in three sentences.");
console.log(turn.result); // model's answer
console.log(turn.trace); // TurnRecord — see Reference below
console.log(niato.metrics(turn.session.id)); // rolling SessionMetricsA more configured example with validators, hooks, persona, and telemetry:
import {
createNiato,
genericPack,
supportPack,
promptInjectionValidator,
maxLengthValidator,
} from "@aman_asmuei/niato";
const niato = createNiato({
packs: [genericPack, supportPack],
inputValidators: [maxLengthValidator(8_000), promptInjectionValidator()],
costLimitUsd: 1.0, // reject further turns once session spends $1
globalHooks: {
// your custom org-wide PreToolUse / PostToolUse / Stop hooks
},
persona: {
name: "Layla",
description: "Warm, faith-aware. Address the user by name.",
},
onTurnComplete: async (trace) => {
// pipe to OTel / Datadog / your time-series store
},
});run() is async, isolated per session, and propagates two typed errors callers can render directly:
| Error | Thrown when | Carries |
| ----- | ----------- | ------- |
| NiatoInputRejectedError | An input validator rejects the message | reason: string |
| NiatoBudgetExceededError | Session cumulative cost ≥ costLimitUsd | cumulativeUsd, limitUsd |
| NiatoAuthError | Neither auth path configured at startup | actionable message |
For non-Niato errors (network, 401, 429, malformed model output), classifyError(err) from @aman_asmuei/niato returns a ClassifiedError with a friendly message — used by the TUI, available to library consumers.
How it works
Every turn is four declarations, in order:
- Input validators run synchronously over the raw message (
maxLengthon by default,promptInjectionopt-in). First failure throwsNiatoInputRejectedErrorbefore any tokens are spent. - Cost-limit gate checks
session.cumulativeCostUsd ≥ costLimitUsd. Pre-turn — never mid-turn. - Classifier (Sonnet 4.6) returns
{ intent, domain, confidence }against the loaded packs' vocabulary. Same SDK as the orchestrator, so OAuth subscription auth Just Works. - Orchestrator (Opus 4.7) reads the classification, picks the pack from
domain, callspack.route(intent)to pick the specialist, and dispatches via the SDK'sAgenttool. The specialist (Sonnet 4.6) does the work using its declared tool allowlist; pack hooks gate every tool call before it executes.
After each turn, a TurnRecord is rebuilt from the SDK's message stream and the per-session ledger updates.
Three architectural invariants worth knowing:
- The orchestrator may only dispatch via the
Agenttool — enforced at the SDK permission layer by the always-onagentOnlyOrchestratorHook. DirectRead/Write/Bash/ MCP calls from the orchestrator are denied. - The classifier is out-of-band — not a tool the orchestrator can call. It runs once before the orchestrator does.
- Subagents do not inherit parent context — anything a specialist needs (file paths, entities, prior decisions) is passed in the dispatch prompt.
For the full design, see ARCHITECTURE.md.
Authentication
Three paths into the Anthropic API; pick whichever matches your setup. All flow through the Agent SDK's auto-resolution — Niato just classifies which one is in use for logging and gates startup until at least one is configured.
| Path | Trigger | Cost | Use case |
| ---- | ------- | ---- | -------- |
| Subscription (interactive) | niato login (sets NIATO_AUTH=subscription, uses ~/.claude/) | $0 — Claude Max quota | Laptop, single user, hands-on. Default for npx @aman_asmuei/niato. ToS note below. |
| Subscription (token) | niato setup-token → export CLAUDE_CODE_OAUTH_TOKEN=ct-... | $0 — Claude Max quota | CI, scripts, containers, anywhere a browser login isn't available. Long-lived (1 year). Same ToS note. |
| API key | ANTHROPIC_API_KEY=sk-ant-... | per-token against your API budget | Production, multi-user, or anywhere a developer API key is the right call |
| None | none set | n/a | NiatoAuthError at startup with actionable message |
createNiato() logs the chosen path at startup. Priority when more than one is configured: CLAUDE_CODE_OAUTH_TOKEN > NIATO_AUTH=subscription > ANTHROPIC_API_KEY — the most specific credential wins, and explicit subscription intent overrides a leftover API key.
The Agent SDK supports OAuth (subscription) authentication via two transport mechanisms — ~/.claude/ session storage (driven by NIATO_AUTH=subscription) and the CLAUDE_CODE_OAUTH_TOKEN env var (long-lived token from claude setup-token). Niato exposes both. They are opt-in only: without one of those signals, Niato uses the developer API path or fails clearly at startup. We made this change because:
- The Agent SDK explicitly supports OAuth (
ApiKeySource = 'oauth'in its types). - What's not explicit is whether Anthropic's Consumer Terms / Acceptable Use Policy permit using your Claude Max subscription to power applications other than Claude Code itself.
- Silently defaulting strangers onto that path would push them onto a ToS-uncertain path without their knowledge.
Shape of the question:
- Probably fine: personal use, low volume, on your own machine. You're already paying for Max; running a personal companion through it is close to the spirit of subscription auth.
- Verify before doing: anything you'd put in front of customers, deploy to production, distribute to other users, or run at sustained volume. The right path for those cases is a developer API key with explicit billing.
The authoritative sources are Anthropic's Consumer Terms, Acceptable Use Policy, the Claude Max subscription agreement, and — for anything ambiguous — Anthropic support directly. Verify before scaling beyond personal use.
Domain packs
A DomainPack is a self-contained bundle: intents, specialist AgentDefinitions, MCP servers, hooks, and a route(intent) ⇒ specialist function. Three packs ship today; the orchestrator can compose dispatches across them in a single turn.
| Pack | Intents | Specialists | MCP | Hooks |
| ---- | ------- | ----------- | --- | ----- |
| Generic | question, task, escalate | retrieval, action, escalate | none | none |
| Support | order_status, refund_request, billing_question, complaint, account_help | ticket_lookup, refund_processor, kb_search, escalate | in-process support_stub (canned responses; production swaps in real Zendesk / Stripe URLs) | piiRedactionHook, dollarLimit({ tool, autoApproveBelow }) |
| Dev Tools | find_code, explain_code, fix_bug, debug_ci | codebase_search, code_explainer, bug_fixer, ci_debugger | none — built-in tools (Read, Grep, Glob, Edit, Bash, WebFetch) | secretsScanHook, sandboxBashHook({ allowedCommands }) |
Adding your own pack: create src/packs/<name>/{pack.ts, agents/, prompts/, evals/} and export a single DomainPack. The Core never imports from inside a pack — only the public interface. Per-pack hooks merge into the orchestrator's Options.hooks after the built-in invariants and any global hooks.
When a single user message genuinely spans multiple packs ("the refund webhook is broken — find the bug and open a ticket"), the classifier extends its output with an optional secondary: SecondaryIntent[] array of additional (intent, domain, confidence) triples. The orchestrator surfaces these as Additional recommendations: in its planning prompt and decides:
- Sequential dispatch when one specialist's output feeds the next (e.g.
dev_tools.bug_fixer→support.escalate). The orchestrator pastes the upstream output into the downstreamAgentprompt — subagents don't share context. - Parallel dispatch when the asks are independent (e.g. an order-status check + an explanation). Both
Agentcalls go out in the same assistant message; the SDK runs them concurrently. - Clarify when a secondary's confidence is below the 0.85 dispatch bar — the orchestrator asks one question rather than guessing.
After every specialist returns, the orchestrator synthesizes a single answer that cites each contributing specialist.
persona?: { name?, description } on NiatoOptions adds a configurable user-facing identity layer. The text is prepended to the orchestrator's system prompt — the orchestrator becomes the persona; specialists stay role-focused tools the persona uses. Pack brand voice (already in each specialist's prompt.md) keeps working unchanged.
persona: {
name: "Layla",
description: "Warm, faith-aware. Address the user by name. Acknowledge difficulty without minimizing.",
}What Level 1 covers: a consistent voice across every turn. What it doesn't: persistent memory, time-of-day modulation, evolving rapport, per-user persona — those are Level 2 / Level 3 work.
memory?: { store?, userId? } on NiatoOptions opts a Niato instance into cross-session memory. Facts are loaded once at createNiato() startup, formatted into a preamble between the persona and the operational prompt, and persisted to the configured MemoryStore. The default FileMemoryStore writes JSON to ~/.niato/memory/<userId>.json; swap in Redis / Postgres / etc. by implementing the two-method interface. Append facts with await niato.remember([...]) — the next turn sees them. Soft-capped at 100 facts / ~4 KB; older facts are dropped with a warn log on overflow. Memory injects into the orchestrator's system prompt only; specialists never see it (architectural invariant #4).
const niato = createNiato({
packs: [genericPack],
memory: { userId: "alice" }, // FileMemoryStore by default
persona: { name: "Layla", description: "Warm, faith-aware companion." },
});
await niato.run("Hi, I'm new here.");
await niato.remember(["Prefers concise answers.", "Lives in Kuala Lumpur."]);
await niato.run("Where can I buy good kopi?"); // sees the remembered factsuserId resolves from NiatoOptions.memory.userId → Config.NIATO_USER_ID → "default". One Niato instance is one user — userId is not accepted per-turn on niato.run(). Auto-extraction of facts from conversation lands in v1.1; for now remember() is the only writer.
Reference
interface TurnRecord {
sessionId: string;
turnId: string; // uuid generated per turn
classification: IntentResult; // { intent, domain, confidence, secondary? }
plan: string[]; // specialist names dispatched, in order
specialists: { name: string; toolCalls: number }[];
tokensByModel: Record<string, {
inputTokens: number;
outputTokens: number;
cacheReadInputTokens: number;
cacheCreationInputTokens: number;
}>;
costUsd: number; // SDK-reported total
latencyMs: number; // wall clock, includes classifier
outcome: "success" | "error";
guardrailsTriggered: string[]; // tool_names denied by any hook this turn
}Matches the per-turn record shape from ARCHITECTURE.md §11 minus cross-turn aggregation and user_id.
Read with niato.metrics(sessionId):
interface SessionMetrics {
turnCount: number;
cumulativeCostUsd: number;
cumulativeLatencyMs: number;
guardrailsTriggered: Record<string, number>; // tool_name → count
dispatchesByPackSpecialist: Record<string, number>; // "support.refund_processor" → count
errorCount: number;
}Returns undefined for unknown sessions. The result is a defensive clone — mutating it does not corrupt the live ledger.
| Layer | Where it runs | Failure mode |
| ----- | ------------- | ------------ |
| Input validators | Before classification | NiatoInputRejectedError |
| Cost-limit gate | Before classification | NiatoBudgetExceededError |
| SDK hooks | Around tool calls (built-in invariants → globalHooks → pack hooks) | SDK permission deny with reason text |
Hooks are enforcement, not logging. The first hook to deny halts the SDK's permission flow before the tool runs. agentOnlyOrchestratorHook and mergeHooks(...layers) are exported so you can reuse them in custom configurations.
Three layers, none of which require an external dependency:
Per-turn TurnRecord — the single-turn shape above. Every niato.run() returns it; the info log line includes it as a flat JSON object.
Per-session SessionMetrics — rolling aggregates updated after each turn settles. Read with niato.metrics(sessionId).
onTurnComplete callback — (trace: TurnRecord) => void | Promise<void>. Fires after each turn's trace is built. Wire OTel / Datadog / Honeycomb / your own time-series store from here. Errors thrown by the callback are caught and logged at warn level; telemetry never breaks user flows.
OpenTelemetry-style distributed tracing isn't bundled — that's a per-deployment decision. A copy-paste OTel adapter recipe (Datadog included via OTLP receiver) lives at docs/otel-adapter.ts with the explainer at docs/otel-adapter.md.
Niato uses the Agent SDK's built-in session management:
- First turn of a session passes
Options.sessionId = <uuid>to the SDK. - Subsequent turns pass
Options.resume = <uuid>so the model sees the prior transcript. - The SDK handles compaction automatically when context fills.
- Transcripts persist at
~/.niato/sdk-sessions/regardless of where you launchedniatofrom.
Pass the same sessionId to two consecutive run() calls and the second one resumes coherently. The TUI's "Resume Last" path uses this transparently.
Each pack ships a baseline.json next to its cases.jsonl. CI uses it to catch silent classifier-quality regressions:
pnpm eval support --baseline # asserts current ≥ baseline; non-zero exit on regression
pnpm eval support --baseline=path.json # custom baseline path (CI artifact stores)
pnpm eval support --write-baseline # records the current score as the new baselineThe check is strict: any drop in passed count fails. Case-count changes (i.e. someone edited cases.jsonl) require an explicit --write-baseline rather than silently passing.
Development
Clone-based workflow:
git clone https://github.com/<your-fork>/niato.git
cd niato
pnpm install
cp .env.example .env # fill in ANTHROPIC_API_KEY for E2E tests
pnpm typecheck && pnpm lint && pnpm test
pnpm dev "explain how DNS works in three sentences"You'll see a structured turn log line (classification, dispatched specialist, tokens, cost, latency) followed by the model's answer.
Useful scripts:
| Command | What it does |
| ------- | ------------ |
| pnpm dev "<prompt>" | Single-turn, Generic pack only |
| pnpm dev:multi "<prompt>" | Single-turn, all packs loaded (cross-pack examples) |
| pnpm dev:tui "<prompt>" | Single-turn through Ink dashboard |
| pnpm chat | Persistent multi-turn REPL with companion persona |
| pnpm typecheck | tsc --noEmit |
| pnpm lint | ESLint with @typescript-eslint/recommended-strict |
| pnpm test | Vitest (offline; E2E suites unskip when ANTHROPIC_API_KEY is set) |
| pnpm eval <pack> | Run a pack's golden suite |
| pnpm build | Production build (dist/ + .md prompt copy) |
| Suite | Path | Runs offline? |
| ----- | ---- | ------------- |
| Wiring | tests/wiring.test.ts | Yes |
| Classifier unit | tests/classifier.test.ts | Yes (Anthropic SDK mocked) |
| Orchestrator enforcement | tests/orchestrator-enforcement.test.ts | Yes |
| Validators | tests/validators.test.ts | Yes |
| Support stub MCP | tests/support-stub.test.ts | Yes |
| Support hooks | tests/support-hooks.test.ts | Yes |
| Dev Tools hooks | tests/dev-tools-hooks.test.ts | Yes |
| Cross-pack orchestrator | tests/cross-pack-orchestrator.test.ts | Yes |
| Trace guardrails extractor | tests/trace-guardrails.test.ts | Yes |
| Session metrics | tests/metrics.test.ts | Yes |
| Conversation memory | tests/conversation-memory.test.ts | Yes |
| Persona | tests/persona.test.ts | Yes |
| TUI screens (ApiKeyEntry, CompanionWizard, etc.) | tests/cli/tui/screens/*.test.tsx | Yes |
| Smoke (E2E) | tests/smoke.test.ts | No — needs ANTHROPIC_API_KEY |
| Support smoke (E2E) | tests/support-smoke.test.ts | No — three real turns, ~$0.15 |
| Dev Tools smoke (E2E) | tests/dev-tools-smoke.test.ts | No — three real turns, ~$0.25 |
| Cross-pack smoke (E2E) | tests/cross-pack-smoke.test.ts | No — one real turn |
| Cost-limit (E2E) | tests/cost-limit-e2e.test.ts | No — two real turns |
| Cross-pack classifier (E2E) | tests/cross-pack-classifier.test.ts | No — eight live cases |
| Pack evals | tests/evals.test.ts | No — Generic / Support / Dev Tools golden suites |
pnpm test picks up ANTHROPIC_API_KEY from .env automatically; the E2E suites un-skip themselves when the key is present.
src/
├── core/ ingress, session, classifier, orchestrator, compose
├── packs/ DomainPack interface + generic/, support/, dev-tools/
├── tools/ built-in tool name constants
├── memory/ session store
├── guardrails/ hooks, validators, errors, orchestrator-enforcement
├── observability/ log, trace
├── evals/ shared runPackEvals helper + CLI runner
├── cli/ error-classify, dispatch, TUI screens
└── index.ts public exportsConventions: TypeScript strict, one AgentDefinition per file, prompts longer than 30 lines in adjacent .md files, single typed config module that fails fast, MCP credentials referenced via env vars never literal strings. See CLAUDE.md.
Releasing
CI/CD lives in .github/workflows/:
ci.yml— runs typecheck + lint + test + build on every push and PR.release.yml— publishes to npm with Sigstore provenance when avX.Y.Ztag is pushed. Requires anNPM_TOKENrepo secret (granular access token, publish-only, bypass-2FA enabled).
Full setup + per-release flow in docs/RELEASING.md. TL;DR for an existing setup:
# Bump version in package.json + CHANGELOG.md, commit, then:
git tag -a vX.Y.Z -m "vX.Y.Z"
git push origin master vX.Y.Z
# CI takes over: builds, validates, publishes to npm.Roadmap & status
v1.0.0 — General Availability. Plans 1–4 of the v1 release roadmap shipped. See docs/CHANGELOG.md for the full history.
| Phase | Version | Theme | | ----- | ------- | ----- | | 1–9 | pre-1.0 | Skeleton → classifier → hooks → packs → cross-pack → observability → persona → OAuth → memory groundwork | | 10 | v0.2.0 | Release prep — license, npm-publishable, NIATO_AUTH opt-in, Node bin dispatcher | | 11 | v0.3.0 | In-app onboarding — Ink-native ApiKeyEntry + CompanionWizard | | 12 | v0.4.0 | Conversation memory — SDK sessionId/resume threading | | 13 | v1.0.0 | Polish — friendly errors, Generic-default packs, CHANGELOG |
Backlog (post-1.0):
- Real GitHub MCP wiring for
pr_creator(currently stubbed viadev_tools_github_stub).
Shipped (post-1.0):
- Auth UX hardening (v1.2.0) —
niato loginnow persists the choice (no more "logged in but logged out"),niato setup-tokenfor CI / headless viaCLAUDE_CODE_OAUTH_TOKEN, three auth paths documented in priority order. - Eval baselines committed for all three packs (
generic20/20,support23/25,dev_tools25/25). CI gate active viapnpm eval <pack> --baseline. - Long-term cross-session memory — file-based default at
~/.niato/memory/<userId>.jsonwith a thinMemoryStoreinterface for plugging in Redis/Postgres later. - TUI multi-turn history dashboard — scrollable session view in the launcher.
- OpenTelemetry adapter — copy-paste recipe at
docs/otel-adapter.ts+ explainer atdocs/otel-adapter.md. Datadog covered via the Agent's OTLP receiver — same code. pr_creatorspecialist +protectedBranchGatehook (with stub MCP — see backlog above for real wiring).
License
MIT · © 2026 Abdul Rahman
Niato declares before it acts.
