LoopEngineBETA

Docs

Documentation

How LoopEngine works, from scaffold to durable approvals — agents, adapters, permission rules, and abilities.

Configure an Agent

Once an agent exists (see Add an Agent), you'll usually come back to adjust its system prompt, model, tools, skills, or permission rules rather than create a new one. Two ways to do that too.

Text editor

There's no CLI subcommand for this — edit the same files add-agent generated, directly:

To change... Edit
System prompt or model agents/<name>/index.ts
Tools agents/<name>/tools/index.ts
Skills agents/<name>/skills/<skill-name>/SKILL.md — one folder per skill
Permission rules agents/<name>/actauth.yml

rules is how you gate what a tool can do without approval — each rule maps a scope (tenant/environment) + tool name to allow/ask/deny, with an ask decision routing to a human. See Human in the loop for the full design — live vs. durable approvals, notification channels, and how resumption works.

A running npx loopengine dev picks up the change on the next request — no restart needed for most edits. index.ts is the one most worth knowing well — every other field lives there, not scattered across files:

import type { AgentConfig } from 'loopengine'
import { tools } from './tools/index.js'

export const config: AgentConfig = {
  name: 'customer-service',
  systemPrompt: 'You help customers with order status and refunds.',
  model: { provider: 'anthropic', model: 'claude-sonnet-5' },
  tools,
  rules: 'agents/customer-service/actauth.yml',
  maxTurns: 25,
}

AgentConfig fields

Field Required? What it is
name Required Also doubles as the ActAuth scope.agent segment.
systemPrompt Required This agent's own instructions.
model Optional Provider + model name. Omit entirely and the file must export its own createModelCall instead — see Basic Configuration.
tools Optional Hand-written tools. Omit to default to agents/<name>/tools/index.ts's exported tools.
rules Optional Inline rule array, or a path to an actauth.yml. Omit to default to agents/<name>/actauth.yml.
defaultDecision Optional Fallback decision when nothing matches — only for the inline-array rules form. Default 'ask'.
skillsDirs Optional SKILL.md folders this agent can use. Omit to default to agents/<name>/skills.
maxTurns Optional Hard cap on model calls in one turn, so a stuck loop can't run forever. Default 25.
toolDescription Optional* Required only if this agent is wrapped as a subagent tool via agentAsTool — the description shown to the parent agent's model.
httpNotifier Optional Where a durable approval/question notification goes on the http channel — see Human in the loop.
tenantFor Optional Resolves the ActAuth tenant from request headers. Omit and every request runs as tenant 'default'.
sessionIdFor Optional Derives a session key from the request body. Omit to use a plain client-supplied sessionId field.
onRunStart / onRunFinish Optional Fire-and-forget hooks for visibility into unattended, http-channel-triggered runs.
contextBudgetTokens / skillIndexBudgetTokens Optional Token budgets before compaction and skill-index trimming kick in.
summarizer Optional How old history gets compacted once over budget. Default: a dependency-free truncate, no model call.
isSafeTool Optional Classifier deciding which tool calls can run in a parallel batch. Omit and each tool's own safe flag is used instead.

Notifying a human (httpNotifier)

Only consulted on the http channel — cli/http_stream always have a live human already attached, nothing to configure. Six channels ship; 'slack' is one of the interactive ones (buttons on the message itself):

export const config: AgentConfig = {
  // ...
  httpNotifier: {
    channel: 'slack',
    config: {
      botToken: process.env.SLACK_BOT_TOKEN!,
      channelId: process.env.SLACK_CHANNEL_ID!,
    },
    events: ['approval', 'question'],
  },
}

events picks which of 'approval'/'question'/'run_start'/ 'run_finish' this channel handles — listing only ['approval'] is a real, common choice (durable approvals, but a live/blocking question flow instead), not partial config. See Human in the loop for 'webhook'/'lark'/'email'/ 'database'/'redis''s own config shapes.

Resolving tenant and session

Both only run on the http channel — runAgent() itself never sees a request, so standalone/CLI callers just get the defaults (tenant: 'default', a client-supplied sessionId field).

export const config: AgentConfig = {
  // ...
  // From a header, never the body — tenant feeds permission decisions
  // directly, so it has to come from something verified. Returning
  // undefined is a real 401, not "fall back to 'default'".
  tenantFor(headers) {
    const apiKey = headers['x-api-key']
    if (apiKey === process.env.ACME_CORP_API_KEY) return 'acme-corp'
    if (apiKey === process.env.INTERNAL_API_KEY) return 'default'
    return undefined
  },
  // From the body — customer-service keys sessions off the customer's
  // email instead of a client-supplied id, so the same customer always
  // resumes the same conversation.
  sessionIdFor(body) {
    const email = body.customerEmail
    return typeof email === 'string' ? `customer:${email}` : undefined
  },
}

Run lifecycle hooks

Fire-and-forget, http-channel-only (same "the channel already delivers this synchronously otherwise" reasoning as above) — visibility into unattended, cron- or webhook-triggered runs:

export const config: AgentConfig = {
  // ...
  onRunStart({ agent, tenant, sessionId, trigger }) {
    console.log(`[${agent}] started — tenant=${tenant} session=${sessionId} trigger=${trigger}`)
  },
  onRunFinish({ agent, tenant, sessionId, stopReason }) {
    console.log(`[${agent}] finished — tenant=${tenant} session=${sessionId}`, stopReason ?? 'done')
  },
}

trigger on onRunStart tells a fresh message ('message') apart from a durable resume ('resolution' — a human just answered/approved something) — check it if you only care about the former. Neither hook is awaited: a returned Promise's rejection is caught and logged, never surfaced to the loop, so a failed announcement can't fail the turn that triggered it.

Using LLMSummarizer

The default summarizer — TruncatingSummarizer — just cuts old messages to fit contextBudgetTokens, no model call. Swap in LLMSummarizer for a real compaction instead: a fact stated once early in a long conversation (an order id, a decision) tends to survive a structured summary, not a hard character cutoff.

import { type AgentConfig, LLMSummarizer, createAnthropicModelCall } from 'loopengine'
import { tools } from './tools/index.js'

export const config: AgentConfig = {
  name: 'customer-service',
  systemPrompt: 'You help customers with order status and refunds.',
  model: { provider: 'anthropic', model: 'claude-sonnet-5' },
  tools,
  contextBudgetTokens: 50_000,
  summarizer: new LLMSummarizer({
    // A second, independent ModelCall from the agent's own `model` above
    // — reads ANTHROPIC_API_KEY. Point it at a cheaper/faster model
    // purely for compaction if you want; the two don't have to match.
    modelCall: createAnthropicModelCall({ model: 'claude-sonnet-5' }),
  }),
}

createAnthropicModelCall — and its createOpenAIModelCall/ createDeepSeekModelCall/createGeminiModelCall/createGlmModelCall/ createKimiModelCall siblings — are the same factory functions AgentConfig.model resolves to internally, exported directly for exactly this kind of manual use (see core/model-calls/*.ts). Pass LLMSummarizerOptions.systemPrompt to replace — not append to — the default compaction instructions. If the model call itself fails (rate limit, network) or returns nothing usable, LLMSummarizer falls back to TruncatingSummarizer rather than failing the whole turn.

Web interface

Open http://localhost:8787/agents/config?agent=<name> (see Web UI for the full tab-by-tab breakdown) — the Overview tab edits system prompt and model live, Skills creates, edits, and deletes SKILL.md files with a markdown preview, Tools connects or removes tools (including external gateways like Composio) without touching a file, and ActAuth adds, edits, and deletes permission rules. Every change takes effect immediately, no redeploy.