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.