Generate and deploy n8n workflows from natural language using a RAG pipeline. Supports 450+ native n8n nodes (Gmail, Slack, Stripe, etc.) with intelligent credential resolution and full workflow lifecycle management.
- Architecture Overview
- Configuration
- Plugin Components
- CREATE_N8N_WORKFLOW — Complete Lifecycle
- RAG Pipeline
- Credential Resolution
- Other Actions
- Providers
- LLM Response Formatting
- Database Schema
- Types Reference
- Project Structure
- Development
┌───────────────────────────────────────────────────────────────────┐
│ ElizaOS Runtime │
│ │
│ ┌─────────────┐ ┌──────────────────┐ ┌──────────────────────┐ │
│ │ Services │ │ Actions │ │ Providers │ │
│ │ │ │ │ │ │ │
│ │ N8nWorkflow │ │ CREATE_N8N_WF │ │ PENDING_DRAFT │ │
│ │ Service │ │ ACTIVATE_N8N_WF │ │ ACTIVE_WORKFLOWS │ │
│ │ │ │ DEACTIVATE_N8N_WF│ │ WORKFLOW_STATUS │ │
│ │ N8nCred │ │ DELETE_N8N_WF │ │ │ │
│ │ Store (DB) │ │ GET_N8N_EXECS │ │ │ │
│ └──────┬──────┘ └──────────────────┘ └──────────────────────┘ │
│ │ │
│ ┌──────┴──────────────────────────────────────────────────────┐ │
│ │ runtime.getCache() │ │
│ │ Per-user draft state machine │ │
│ │ Key: workflow_draft:{userId} — TTL: 30 min │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────┐ ┌────────────────────────────────────────┐ │
│ │ Database │ │ LLM (via runtime.useModel) │ │
│ │ PostgreSQL │ │ │ │
│ │ n8n_workflow │ │ TEXT_LARGE ─── workflow generation │ │
│ │ .credential_ │ │ TEXT_SMALL ─── response formatting │ │
│ │ mappings │ │ OBJECT_SMALL ─ classification/extract │ │
│ └──────────────────┘ └────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────┘
│ │
▼ ▼
┌──────────────────┐ ┌────────────────────────┐
│ n8n REST API │ │ External CredProvider │
│ /api/v1/ │ │ (optional, e.g. OAuth) │
│ workflows │ │ │
│ executions │ │ resolve(userId, type) │
│ tags │ │ → resolved / needs_auth │
│ credentials │ │ │
└──────────────────┘ └────────────────────────┘
| Setting | Description | Example |
|---|---|---|
N8N_API_KEY |
Your n8n instance API key | n8n_api_abc123... |
N8N_HOST |
Your n8n instance URL | https://your.n8n.cloud |
Static credential IDs bypass the resolution chain for known services:
{
"name": "AI Workflow Builder",
"plugins": ["@elizaos/plugin-n8n-workflow"],
"settings": {
"N8N_API_KEY": "env:N8N_API_KEY",
"N8N_HOST": "https://your.n8n.cloud",
"workflows": {
"credentials": {
"gmailOAuth2": "cred_gmail_123",
"stripeApi": "cred_stripe_456"
}
}
}
}Register a service implementing CredentialProvider on the runtime (type n8n_credential_provider) to handle OAuth flows automatically:
interface CredentialProvider {
resolve(userId: string, credType: string): Promise<
| { status: 'resolved'; credentialId: string }
| { status: 'needs_auth'; authUrl: string }
| null
>;
}| Service | Type | Description |
|---|---|---|
N8nWorkflowService |
n8n_workflow |
RAG pipeline orchestrator, workflow CRUD, execution management |
N8nCredentialStore |
n8n_credential_store |
PostgreSQL-backed credential mapping: (userId, credType) → credId |
| Action | Similes | Description |
|---|---|---|
CREATE_N8N_WORKFLOW |
create, build, generate, confirm, deploy, cancel | Full lifecycle: generate, preview, modify, deploy |
ACTIVATE_N8N_WORKFLOW |
activate, enable, start, turn on | Activate a workflow (+ draft redirect) |
DEACTIVATE_N8N_WORKFLOW |
deactivate, disable, stop, pause, turn off | Deactivate a running workflow |
DELETE_N8N_WORKFLOW |
delete, remove, destroy | Permanently delete a workflow |
GET_N8N_EXECUTIONS |
executions, history, runs | Show execution history (last 10 runs) |
| Provider | Name | Runs On | Description |
|---|---|---|---|
pendingDraftProvider |
PENDING_WORKFLOW_DRAFT |
Every message | Injects draft context into LLM state for action routing |
activeWorkflowsProvider |
ACTIVE_N8N_WORKFLOWS |
Every message | User's workflow list (up to 20) for semantic matching |
workflowStatusProvider |
n8n_workflow_status |
Every message | Workflow status with last execution info (up to 10) |
This is the main action. It operates as a cache-based state machine: each invocation checks runtime.getCache() for an existing draft and branches accordingly. A single action handles all states — generation, preview, clarification, modification, confirmation, cancellation, and deployment.
Handler Entry
│
├─── Service unavailable? ─────────────────────────────────→ ERROR
│
├─── Draft in cache?
│ │
│ ├── Expired (> 30 min)? ─── delete cache ──────────→ (treat as no draft)
│ │
│ └── Valid draft ─── classifyDraftIntent (LLM)
│ │
│ ├── confirm + no pending clarification
│ │ │
│ │ └── deployWorkflow
│ │ │
│ │ ├── Unresolved credentials? ──────→ AUTH_REQUIRED
│ │ │ (draft KEPT in cache)
│ │ │
│ │ └── All resolved ─────────────────→ DEPLOY_SUCCESS
│ │ (draft CLEARED)
│ │
│ ├── confirm + has pending clarification ──────→ (override to modify)
│ │
│ ├── cancel ───────────────────────────────────→ CANCELLED
│ │ (draft CLEARED)
│ │
│ ├── modify
│ │ │
│ │ └── modifyWorkflowDraft
│ │ │
│ │ ├── Needs clarification? ─────────→ CLARIFICATION
│ │ │ (draft UPDATED)
│ │ │
│ │ └── Complete ─────────────────────→ PREVIEW
│ │ (draft UPDATED)
│ │
│ ├── new ─── delete cache + generateAndPreview
│ │ │
│ │ ├── Generation failed? ─────────────────→ PREVIEW
│ │ │ (previous draft RESTORED, restoredAfterFailure)
│ │ │
│ │ └── Success ────────────────────────────→ (see generateAndPreview)
│ │
│ └── unknown intent ───────────────────────────→ PREVIEW
│ (re-show current draft)
│
└─── No draft
│
├── Empty text? ───────────────────────────────────→ EMPTY_PROMPT
│
└── Has text ─── generateAndPreview
│
├── Needs clarification? ─────────────────────→ CLARIFICATION
│ (draft STORED)
│
└── Complete ─────────────────────────────────→ PREVIEW
(draft STORED)
Global catch ──────────────────────────────────────────────────→ ERROR
service = runtime.getService(N8N_WORKFLOW_SERVICE_TYPE)
// If null → formatActionResponse(runtime, 'ERROR', { error }) → return { success: false }userText = message.content.text.trim()
userId = message.entityId
cacheKey = `workflow_draft:${userId}` // one draft per userexistingDraft = await runtime.getCache<WorkflowDraft>(cacheKey)
// TTL check: Date.now() - createdAt > 30 min → deleteCache, treat as no draftA WorkflowDraft contains:
| Field | Type | Description |
|---|---|---|
workflow |
N8nWorkflow |
Generated workflow (validated, positioned, no creds) |
prompt |
string |
Original user prompt |
userId |
string |
For credential resolution at deploy time |
createdAt |
number |
Date.now() timestamp for 30-min TTL |
intentResult = await classifyDraftIntent(runtime, userText, existingDraft)Calls ModelType.OBJECT_SMALL with:
- System prompt:
DRAFT_INTENT_SYSTEM_PROMPT - Draft summary: workflow name, node list, original prompt
- Current user message
Returns:
{ intent: 'confirm' | 'cancel' | 'modify' | 'new', modificationRequest?: string, reason: string }Fallback: if the LLM throws or returns an invalid/missing intent → show_preview (re-displays the draft).
IF intent === 'confirm'
AND draft.workflow._meta.requiresClarification.length > 0
THEN effectiveIntent = 'modify'
Why: when the draft has pending questions (e.g. "Which email address?") and the user answers "john@example.com", the LLM classifies this as "confirm" (the user responded). But we must not deploy an incomplete draft — we regenerate with the user's answer incorporated as a modification.
deployWorkflow(existingDraft.workflow, userId)
│
├── resolveCredentials (see Credential Resolution section)
│
├── ANY credential unresolved?
│ YES → return { id: '', missingCredentials: [...] }
│ → action shows AUTH_REQUIRED with auth links
│ → draft STAYS in cache for retry
│ NO → continue
│
├── client.createWorkflow(workflow) ← POST /api/v1/workflows
│
├── client.activateWorkflow(id) ← POST /api/v1/workflows/{id}/activate
│ (try/catch — failure = created but inactive)
│
├── client.getOrCreateTag(tagName) ← per-user tagging
│ client.updateWorkflowTags(id, [tagId])
│ (try/catch — tagging is optional)
│
└── return { id, name, active, nodeCount, missingCredentials: [] }
→ action deletes cache
→ action shows DEPLOY_SUCCESS
delete cache → formatActionResponse('CANCELLED', { workflowName }) → return
modification = intentResult.modificationRequest || userText
modifiedWorkflow = await service.modifyWorkflowDraft(existingDraft.workflow, modification)modifyWorkflowDraft performs:
1. collectExistingNodeDefinitions(existingWorkflow)
→ get catalog definitions for nodes already in the workflow
2. extractKeywords(runtime, modificationRequest)
→ LLM extracts keywords from the modification request
3. searchNodes(keywords, 10)
→ find new nodes the modification might need
4. Deduplicate: merge existing + new definitions
5. modifyWorkflow(runtime, existingWorkflow, modification, combinedDefs)
→ LLM (TEXT_LARGE, temperature 0, JSON mode)
→ modifies workflow JSON, keeping unchanged nodes intact
6. validateWorkflow(result)
→ structure validation
7. injectCatalogClarifications(result)
→ check required params + disconnected inputs from node catalog
8. positionNodes(result)
→ BFS left-to-right layout
After return:
- Store updated draft in cache (new
createdAt) - If
_meta.requiresClarificationhas items →CLARIFICATION - Otherwise →
PREVIEWwith updated workflow data
The user wants a completely different workflow.
1. Empty text? → EMPTY_PROMPT + delete cache + return false
2. Delete cache
3. try: generateAndPreview(...)
4. catch:
→ restore previous draft in cache
→ PREVIEW with restoredAfterFailure: true
→ LLM mentions the new request failed, shows previous draft
Re-show current draft PREVIEW → return { success: true }
Empty text? → EMPTY_PROMPT → return { success: false }
Has text? → generateAndPreview(runtime, service, text, userId, cacheKey, callback)
async function generateAndPreview(runtime, service, prompt, userId, cacheKey, callback)
│
├── service.generateWorkflowDraft(prompt)
│ └── Full RAG pipeline (see RAG Pipeline section)
│
├── Store draft: { workflow, prompt, userId, createdAt: Date.now() }
│
├── workflow._meta.requiresClarification has items?
│ YES → formatActionResponse('CLARIFICATION', { questions })
│ NO → formatActionResponse('PREVIEW', buildPreviewData(workflow))
│
└── return { success: true }
{
workflowName: "Daily Stripe Summary via Gmail",
nodes: [
{ name: "Schedule Trigger", type: "scheduleTrigger" },
{ name: "Stripe", type: "stripe" },
{ name: "Gmail", type: "gmail" }
],
flow: "Schedule Trigger → Stripe → Gmail", // BFS traversal from triggers
credentials: ["stripeApi", "gmailOAuth2"],
assumptions: ["Running daily at 9 AM"], // if LLM made assumptions
suggestions: ["Consider adding error handling"], // if LLM has suggestions
restoredAfterFailure: true // only when "new" generation fails
}buildFlowChain: BFS traversal of connections starting from nodes with no incoming edges (triggers), producing A → B → C.
| Type | When | Data Sent to LLM | Cache Effect |
|---|---|---|---|
PREVIEW |
New draft or modification | workflowName, nodes, flow, credentials, assumptions | Draft stored/updated |
CLARIFICATION |
Vague prompt or missing params | questions[] | Draft stored/updated |
DEPLOY_SUCCESS |
Confirm + all credentials resolved | workflowName, workflowId, nodeCount, active | Draft cleared |
AUTH_REQUIRED |
Confirm + unresolved credentials | connections[{service, authUrl}] | Draft kept |
CANCELLED |
Cancel | workflowName | Draft cleared |
EMPTY_PROMPT |
Empty text | {} | Draft cleared (if any) |
ERROR |
Any exception | error message | Unchanged |
generateWorkflowDraft orchestrates the full pipeline:
User Prompt: "Send me Stripe payment summaries every Monday via Gmail"
│
▼
┌──────────────────────────────────────────┐
│ 1. extractKeywords (OBJECT_SMALL) │
│ Input: user prompt │
│ Output: ["stripe", "gmail", "email", │
│ "schedule", "payment"] │
│ Max: 5 keywords │
└──────────────────┬───────────────────────┘
▼
┌──────────────────────────────────────────┐
│ 2. searchNodes (local catalog) │
│ 457 embedded n8n node definitions │
│ Keyword scoring: │
│ exact name match = 10 pts │
│ partial name match = 5 pts │
│ category match = 3 pts │
│ description match = 2 pts │
│ individual word = 1 pt │
│ Returns: top 15 nodes by score │
│ Throws if 0 results │
└──────────────────┬───────────────────────┘
▼
┌──────────────────────────────────────────┐
│ 3. generateWorkflow (TEXT_LARGE) │
│ Input: user prompt + node defs │
│ Config: temperature 0, JSON mode │
│ Output: complete n8n workflow JSON │
│ Includes: _meta.assumptions, │
│ _meta.suggestions, │
│ _meta.requiresClarification│
└──────────────────┬───────────────────────┘
▼
┌──────────────────────────────────────────┐
│ 4. validateWorkflow │
│ - nodes array exists, non-empty │
│ - connections object valid │
│ - required fields on each node │
│ - no duplicate node names │
│ - valid positions (auto-fix if not) │
│ - connection integrity │
│ - trigger detection │
│ - orphan node detection │
│ Throws on validation errors │
└──────────────────┬───────────────────────┘
▼
┌──────────────────────────────────────────┐
│ 5. injectCatalogClarifications │
│ Check each node against catalog: │
│ - validateNodeParameters │
│ → missing required params? │
│ - validateNodeInputs │
│ → expected inputs not connected? │
│ Appends to _meta.requiresClarification│
└──────────────────┬───────────────────────┘
▼
┌──────────────────────────────────────────┐
│ 6. positionNodes │
│ BFS layout from trigger nodes: │
│ - Triggers at x=250 │
│ - Each level: x += 250 │
│ - Nodes in same level: y spacing 100 │
│ - Centered vertically │
│ Skip if all nodes already positioned │
└──────────────────────────────────────────┘
When deploying, credentials are resolved through a 4-step priority chain. All unresolved credentials block deployment — the workflow is never created on n8n without full credential resolution.
For each credType required by the workflow:
│
├── 1. Credential Store (DB)
│ credStore.get(userId, credType)
│ Previously cached from successful resolutions
│ └── Found → use it ✓
│
├── 2. Static Config
│ character.settings.workflows.credentials[credType]
│ Name tolerance: "gmailOAuth2" ↔ "gmailOAuth2Api"
│ └── Found → use it ✓
│
├── 3. External Provider
│ credProvider.resolve(userId, credType)
│ │
│ ├── { status: 'resolved', credentialId }
│ │ → cache in DB for next time → use it ✓
│ │
│ ├── { status: 'needs_auth', authUrl }
│ │ → add to missingConnections (with authUrl) ✗
│ │
│ └── null → fall through
│
└── 4. Missing
Add to missingConnections (without authUrl) ✗
resolveCredentials returns missingConnections[]
│
├── missingConnections.length > 0
│ → Deploy BLOCKED
│ → Return { id: '', missingCredentials: [...] }
│ → Action shows AUTH_REQUIRED with auth links
│ → Draft stays in cache
│ → User connects services, comes back to say "deploy"
│ → On retry: resolveCredentials checks again → resolved this time
│
└── missingConnections.length === 0
→ All resolved
→ Inject credential IDs into workflow nodes
→ POST /api/v1/workflows → activate → tag
→ Return { id, name, active, nodeCount, missingCredentials: [] }
Resolved credential IDs are injected into the workflow nodes:
// Before injection
node.credentials = {
gmailOAuth2Api: { id: "PLACEHOLDER", name: "Gmail" }
}
// After injection (credentialMap has gmailOAuth2Api → "cred_gmail_123")
node.credentials = {
gmailOAuth2Api: { id: "cred_gmail_123", name: "Gmail" }
}The resolver handles naming mismatches between LLM output and config:
gmailOAuth2matches config keygmailOAuth2Api(appendsApi)gmailOAuth2Apimatches config keygmailOAuth2(stripsApi)
Activates a workflow to start processing triggers.
Draft redirect: if a pending draft exists when ACTIVATE is triggered, the handler assumes the LLM misrouted a confirmation and deploys the draft instead.
ACTIVATE handler
│
├── Pending draft (not expired)?
│ │
│ ├── Draft needs clarification? → prompt user
│ │
│ └── Draft complete → deployWorkflow
│ │
│ ├── Unresolved credentials? → show auth links (draft kept)
│ │
│ └── Deployed → show success (draft cleared)
│
└── No draft → semantic workflow matching → activate matched workflow
Deactivates a workflow to stop automatic execution.
1. List user's workflows
2. matchWorkflow (LLM semantic matching)
3. confidence > none → deactivate matched workflow
confidence = none → list all workflows, ask user to specify
Permanently deletes a workflow. Same semantic matching flow as DEACTIVATE.
Shows execution history for a workflow (last 10 runs).
1. Get workflowId from state
2. Fetch executions from n8n API
3. Format with status emojis: ✅ success, ❌ error, ⏳ running, ⏸️ other
4. Show start time, finish time, error messages
Critical for action routing. Without this provider, the LLM would route confirmation messages ("yes", "deploy it") to the generic REPLY action instead of CREATE_N8N_WORKFLOW.
When a draft exists in cache:
- Injects a context block into the LLM state
- Describes the pending draft (name, nodes)
- Instructs the LLM that ANY message about the draft MUST trigger
CREATE_N8N_WORKFLOW - Sets
data.hasPendingDraft = trueandvalues.hasPendingDraft = true
When no draft exists (or expired):
- Returns empty:
{ text: '', data: {}, values: {} }
Enriches LLM context with the user's workflow list (up to 20 workflows). This enables semantic matching — the LLM can see workflow names, IDs, and statuses when the user says "activate my Stripe workflow".
Shows detailed workflow status including last execution info. Displays up to 10 workflows with status emojis (✅ active, ⏸️ inactive) and last run result.
All user-facing messages from CREATE_N8N_WORKFLOW are generated by the LLM via formatActionResponse. No hardcoded strings.
formatActionResponse(runtime, responseType, data)
│
▼
runtime.useModel(TEXT_SMALL, {
prompt: ACTION_RESPONSE_SYSTEM_PROMPT
+ "\n\nType: " + responseType
+ "\n\n" + JSON.stringify(data)
})
│
▼
LLM composes response in the user's conversation language
│
▼
callback({ text: response }) → sent verbatim to user
System prompt rules:
- Include ALL provided data exactly (names, IDs, URLs) — never omit, never modify
- ONLY use information from the provided data — never invent details
- Be concise — no filler
Multi-language support: since the LLM generates the response text, it naturally responds in whatever language the conversation is happening in. No translation tables or locale files needed.
PostgreSQL schema n8n_workflow with one table, managed by Drizzle ORM:
CREATE SCHEMA n8n_workflow;
CREATE TABLE n8n_workflow.credential_mappings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id TEXT NOT NULL,
cred_type TEXT NOT NULL,
n8n_credential_id TEXT NOT NULL,
created_at TIMESTAMP DEFAULT now() NOT NULL,
updated_at TIMESTAMP DEFAULT now() NOT NULL
);
CREATE UNIQUE INDEX idx_user_cred ON n8n_workflow.credential_mappings (user_id, cred_type);Used by N8nCredentialStore to cache resolved credential IDs. Upsert on conflict (userId, credType) — when a credential is re-resolved, updated_at is refreshed.
interface N8nWorkflow {
name: string;
nodes: N8nNode[];
connections: N8nConnections;
active?: boolean;
settings?: {
executionOrder?: 'v1' | 'v0';
timezone?: string;
// ... other execution settings
};
_meta?: WorkflowMeta; // internal only — NOT sent to n8n API
}
interface WorkflowMeta {
assumptions?: string[]; // LLM assumptions shown in preview
suggestions?: string[]; // LLM suggestions shown in preview
requiresClarification?: string[]; // questions for the user
}
interface N8nNode {
name: string;
type: string; // e.g. "n8n-nodes-base.gmail"
typeVersion: number;
position: [number, number];
parameters: Record<string, unknown>;
credentials?: Record<string, { id: string; name: string }>;
disabled?: boolean;
// ... other optional fields (notes, color, continueOnFail, etc.)
}
interface N8nConnections {
[nodeName: string]: {
[outputType: string]: Array<Array<{
node: string;
type: string;
index: number;
}>>;
};
}interface WorkflowDraft {
workflow: N8nWorkflow;
prompt: string;
userId: string;
createdAt: number; // Date.now() — for 30-min TTL
}
interface DraftIntentResult {
intent: 'confirm' | 'cancel' | 'modify' | 'new' | 'show_preview';
modificationRequest?: string;
reason: string;
}type CredentialProviderResult =
| { status: 'resolved'; credentialId: string }
| { status: 'needs_auth'; authUrl: string }
| null;
interface MissingConnection {
credType: string;
authUrl?: string; // present if needs_auth
}
interface CredentialResolutionResult {
workflow: N8nWorkflow; // with injected credential IDs
missingConnections: MissingConnection[]; // all unresolved credentials
injectedCredentials: Map<string, string>; // credType → n8nCredId
}
interface WorkflowCreationResult {
id: string; // '' if deploy blocked
name: string;
active: boolean;
nodeCount: number;
missingCredentials: MissingConnection[]; // empty if all resolved
}interface WorkflowMatchResult {
matchedWorkflowId: string | null;
confidence: 'high' | 'medium' | 'low' | 'none';
matches: Array<{ id: string; name: string; score: number }>;
reason: string;
}src/
├── index.ts # Plugin registration (services, actions, providers, schema)
├── actions/
│ ├── createWorkflow.ts # CREATE — draft/preview/confirm state machine
│ ├── activateWorkflow.ts # ACTIVATE — with draft redirect logic
│ ├── deactivateWorkflow.ts # DEACTIVATE — semantic matching
│ ├── deleteWorkflow.ts # DELETE — semantic matching
│ └── getExecutions.ts # GET_EXECUTIONS — execution history
├── providers/
│ ├── pendingDraft.ts # Injects draft context for LLM routing
│ ├── activeWorkflows.ts # User's workflow list for semantic matching
│ └── workflowStatus.ts # Workflow status + last execution
├── services/
│ ├── n8n-workflow-service.ts # Main service (RAG pipeline, deploy, CRUD)
│ └── n8n-credential-store.ts # DB-backed credential store (Drizzle ORM)
├── prompts/
│ ├── workflowGeneration.ts # System prompt for workflow generation
│ ├── keywordExtraction.ts # System prompt for keyword extraction
│ ├── workflowMatching.ts # System prompt for semantic matching
│ ├── draftIntent.ts # System prompt for intent classification
│ └── actionResponse.ts # System prompt for response formatting
├── schemas/
│ ├── keywordExtraction.ts # JSON schema for keyword output
│ ├── workflowMatching.ts # JSON schema for matching output
│ └── draftIntent.ts # JSON schema for intent output
├── types/
│ └── index.ts # All TypeScript interfaces and types
├── db/
│ └── schema.ts # Drizzle ORM schema (PostgreSQL)
└── utils/
├── api.ts # n8n REST API client (workflows, executions, tags, creds)
├── catalog.ts # Node catalog (457 nodes) + keyword scoring
├── context.ts # Conversation context builder + user tag naming
├── credentialResolver.ts # 4-step credential resolution chain
├── generation.ts # LLM utilities (extract, generate, match, classify, format)
└── workflow.ts # Validation, positioning, auto-fix
bun install # install dependencies
bun run build # compile TypeScript
bun test # run tests (162 tests)
bun run lint # lint
bun run format # formatMIT