@lorenzoangelini1995/musubi
v0.1.5
Published
AI orchestration layer with Gemini client, HITL support, and cost tracking
Downloads
797
Readme
Musubi 結び
Generic AI orchestration layer for Expo + Supabase apps. Typed Gemini client, intent-based orchestrator, HITL pattern, cost tracking.
Named after the Shinto concept of musubi (結び) — the generative force that creates and connects. An AI that generates adventures, validates photos, and orchestrates complex flows.
Features
- Typed Gemini client —
generate,chat,visionwith full TypeScript types - Intent-based orchestrator — register handlers, route requests, handle errors uniformly
- HITL pattern — built-in Human-in-the-Loop for multi-step flows
- Runtime-agnostic input contract — core models human input needs, app layers map them to concrete UI
- Cost tracking — per-request token usage and USD cost for every Gemini call
- JSON mode — structured output with automatic parsing and markdown fence fallback
- Provider-ready — designed to run in both Node.js (monorepo) and Deno (Supabase Edge Functions via
npm:)
Installation
pnpm add "@lorenzoangelini/musubi@workspace:*"For Supabase Edge Functions (Deno):
import { GeminiClient, BaseOrchestrator } from 'npm:@lorenzoangelini/musubi'GeminiClient
Typed wrapper around @google/generative-ai with built-in cost tracking.
Text generation
import { GeminiClient } from '@lorenzoangelini/musubi'
const gemini = new GeminiClient(process.env.GEMINI_API_KEY!)
const result = await gemini.generate(
'Write a short mystery riddle set in Tokyo.',
{ model: 'gemini-2.5-flash', temperature: 0.8 }
)
console.log(result.text)
console.log(`Cost: $${result.costUsd.toFixed(5)} — ${result.inputTokens}in / ${result.outputTokens}out tokens`)Structured JSON output
type Adventure = { title: string; checkpoints: { name: string; hint: string }[] }
const result = await gemini.generate(prompt, { json: true })
const adventure = gemini.parseJSON<Adventure>(result)
// adventure.checkpoints[0].name — fully typedMulti-turn chat
const result = await gemini.chat([
{ role: 'user', content: 'Create an adventure in Kyoto, history theme.' },
{ role: 'model', content: 'Sure! How long should it be?' },
{ role: 'user', content: '90 minutes, on foot.' },
], { model: 'gemini-2.5-flash' })Vision (photo validation)
const result = await gemini.vision(
'Does this photo show a maneki-neko (lucky cat)? Reply with JSON: { match: boolean, confidence: number, reason: string }',
{ mimeType: 'image/jpeg', data: base64Image },
{ json: true, model: 'gemini-2.5-flash' }
)
const { match, confidence } = gemini.parseJSON<{ match: boolean; confidence: number }>(result)Models
| Model | Best for | Input $/1M | Output $/1M |
|-------|----------|-----------|------------|
| gemini-2.5-flash | Generation, vision | $0.30 | $2.50 |
| gemini-2.5-flash-lite | Simple tasks, high volume | $0.10 | $0.40 |
| gemini-2.0-flash | Fast multimodal | $0.10 | $0.40 |
Default model: gemini-2.5-flash-lite.
BaseOrchestrator
Register intent handlers and route requests through a uniform interface.
import { BaseOrchestrator } from '@lorenzoangelini/musubi'
import type { IntentHandler } from '@lorenzoangelini/musubi'
// Define a handler
const createAdventureHandler: IntentHandler<CreateAdventurePayload, Adventure> = {
intent: 'create_adventure',
async handle(req, ctx) {
// ... generate with Gemini
return { status: 'done', result: adventure, runId: ctx.runId }
},
}
// Build the orchestrator
const orchestrator = new BaseOrchestrator()
.register(createAdventureHandler)
.register(validateCheckpointHandler)
.register(requestHintHandler)
// Run
const response = await orchestrator.run({
intent: 'create_adventure',
payload: { city: 'Tokyo', theme: 'historic', durationMinutes: 90 },
context: { userId: 'uuid-123', location: { lat: 35.6762, lng: 139.6503 } },
})HITL — Human in the Loop
When the AI needs user input mid-flow, return waiting_human and resume later with the same runId.
Musubi describes the input abstractly; your app or runtime maps it to a concrete widget.
const createAdventureHandler: IntentHandler = {
intent: 'create_adventure',
async handle(req, ctx) {
const { theme, city } = req.payload
// Missing required slot — ask the user
if (!theme) {
return ctx.requestHITL({
runId: ctx.runId,
question: 'What theme would you like for the adventure?',
input: {
kind: 'single_select',
label: 'Choose a theme',
options: [
{ label: 'Historic', value: 'historic' },
{ label: 'Food', value: 'food' },
{ label: 'Mystery', value: 'mystery' },
],
metadata: { uiType: 'chip_selector' },
},
slotsFilled: ['city'],
slotsMissing: ['theme'],
})
}
// All slots filled — generate
const result = await generateAdventure({ city, theme })
return { status: 'done', result, runId: ctx.runId }
},
}
// Client resumes with user's choice
const response = await orchestrator.run({
intent: 'resume',
runId: previousRunId, // same run
payload: { user_response: 'historic' },
context: { userId },
})Cost tracking
import { calculateCost, formatCost, aggregateCost } from '@lorenzoangelini/musubi'
const cost = calculateCost('gemini-2.5-flash', 1200, 800)
console.log(formatCost(cost)) // "$0.0024"
// Aggregate across multiple calls
const total = aggregateCost([usage1, usage2, usage3])OrchestratorResponse shape
type OrchestratorResponse<T> =
| { status: 'done'; result: T; runId: string }
| { status: 'waiting_human'; runId: string; message: string; input: HumanInputSchema; slotsFilled: string[]; slotsMissing: string[] }
| { status: 'error'; runId: string; code: string; message: string }Example input contract:
type HumanInputSchema = {
kind: 'text' | 'single_select' | 'multi_select' | 'number' | 'confirm' | 'media'
label: string
options?: { label: string; value: string }[]
min?: number
max?: number
defaultValue?: unknown
metadata?: Record<string, unknown>
}Architecture
src/
clients/
gemini.ts Typed Gemini wrapper — generate, chat, vision, parseJSON
orchestrator/
base.ts BaseOrchestrator — register(), run(), error boundary
utils/
cost.ts calculateCost, formatCost, aggregateCost
types.ts All shared types
index.tsMusubi is intentionally generic — no Kibbo-specific logic. App-level intent handlers live in the Supabase Edge Function layer and use Musubi as a dependency.
License
Private — part of the Kibbo monorepo.
