Agent — anatomy & launch
An agent is config + state, not a container
The single most important thing: an agent is a row in Postgres, not a running process. It is "always available" because it's data — definition + memory + history. It has no pod of its own. When someone messages it, an ephemeral brain (the api image, run per-turn) "wears" that data, runs one turn, and disappears.
Agent (DB row) ──loaded per turn──▶ ephemeral brain (api) ──hands when needed──▶ worker
config + state orchestrator + LLM loop (k8s Job)What an agent is made of
The agent slice group (layer L5) is the agent and everything it owns or uses:
| part (slice) | what it contributes to the agent |
|---|---|
agent | the definition: soul, user, heartbeat, config, runtimeProfile, type, status |
memory | the agent's own long-term recall (vectors) — what it remembers across chats |
knowledge | curated RAG corpus (LightRAG) the agent can search — uploaded docs, per-base |
chat | conversation history (threads, messages) — the running context |
app | external connectors (OAuth / cookies) the agent uses as tools (Google, X, …) |
channel | the surfaces it talks on (telegram, slack, web widget) |
orchestrator | the brain that assembles all of the above per turn and runs the loop |
memory≠knowledge: memory is the agent's own evolving recall; knowledge is a curated corpus a user uploads. Both feed the turn, but they're different sources.
The Agent entity
model Agent {
id String @id // agent-{uuid}; a concierge's is derived from its team's
teamId String // tenant
externalId String? // package origin id — the re-import key, scoped per team
ownerId String // the member that owns it
name String
description String?
status String @default("active") // active | disabled | archived
type String @default("standard") // standard | concierge — server-assigned only
soul String? // SOUL.md — the persona, verbatim
user String? // USER.md — per-user context, verbatim
heartbeat String? // HEARTBEAT.md — the packaged autostart text, verbatim
config Json @default("{}") // the runtime-compatible agent.config.json, verbatim
runtimeProfile String @default("none") // may this agent have hands, and how much machine
promptLogEnabled Boolean @default(false) // record this agent's assembled prompts
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}Creating an agent = inserting this row. It is instantly usable — no provisioning, no pod.
Two of these columns are worth reading twice, because both are the narrow default on purpose and a release must never widen them for an existing agent: runtimeProfile is none (no hands), and promptLogEnabled is false (nothing recorded). See Hands and What the agent did.
The three verbatim documents are the agent's own text, not a rendering. soul, user and heartbeat are the files a package carries; they go out of an export byte for byte and come back the same way.
Deleting the row now takes everything with it. Its conversations, its notes, its skills, its secrets and its autostart config are all cascaded in one statement, so a delete interrupted half-way removes nothing rather than leaving a shell. The one thing outside the cascade is the agent's vectors — they carry no foreign key by design — and they are swept explicitly.
What an agent is, and what its type allows
Every agent carries a type. The type — not a scatter of booleans — answers "what is this?", and everything else follows from it. Two exist today:
| type | what it is |
|---|---|
standard | everything an owner creates; the default for every row |
concierge | the team's system agent. Persona ships in code; it cannot be renamed, disabled, archived, deleted or exported, and it has no secrets, no skills and no autostart |
The type is never an input. Only the server assigns it. It is absent from the create body, from the update body and from the package manifest — otherwise an uploaded package could declare itself the team's concierge and inherit everything the type grants, starting with "cannot be deleted".
One declaration, two readers. What a type allows is a single table (AGENT_CAPABILITIES): may the soul be edited, where the system prompt comes from, may it be renamed / disabled / archived / deleted, does it have secrets / autostart / skills, may it be exported or overwritten by an import, and which toolset it gets. Every write path reads that table — update, status change, delete, the package importer, the package exporter, and the concierge's own create_agent tool. The interface reads the same value: it rides on every agent response as capabilities, so the app keeps no second copy that could drift.
A type the code does not recognise (a row written by a newer release) reads as standard — the fewest powers, never the most.
Archiving is the concierge's; deleting is a person's
The concierge can put an agent in the archive by conversation, and take it back out. It cannot delete one, by any tool, under any flag, and no unused method waits to be switched on.
The line is drawn at reversibility, not at danger in general: archiving is a status change and one movement undoes it, while deleting takes the agent's conversations, notes, skills and secrets with it and nothing brings them back. A model's mistake on the first costs a click; on the second it costs everything that was put into that agent.
Two things must both be true for the concierge to act — the agent may be archived at all, and the concierge is allowed to do it — and both are read from the same capability declaration rather than checked again somewhere in the tool. That is what stops two copies of one rule from drifting apart.
Deleting is in the cabinet, on the agent's own General tab, and the confirmation names the agent rather than asking "are you sure?" — the person has to read what is about to disappear.
How a turn launches the agent
There is no "start the agent" step. The agent wakes per turn:
1. message arrives on a channel → ingress resolves (agentId, sessionId), access check
2. orchestrator (api, ephemeral) ASSEMBLES the agent:
• load Agent definition (soul, config, runtimeProfile, type)
• recall memory context (memory) + read chat history (chat)
• resolve attached knowledge bases (knowledge) + connector creds (app, secrets)
• enforcement check (setting) — may the team spend?
3. build the prompt → run the LLM loop (system/llm), streaming tokens out
4. tool-analysis:
• no tools → stream the answer directly → persist → DONE (no pod)
• tools → tasks (BullMQ) → worker (k8s Job) does bash/fs/browser → results back
5. persist: assistant message (chat), memory updates, usage events
6. the ephemeral brain EXITS — the agent is again just a DB rowA follow-up message repeats this; if a runtime session is still warm, the worker is reused (no cold start).
Tools & obligations
The agent's tools are split by where they run:
- In the brain (
api): memory, chat lookup, secrets, current time, knowledge query. No pod needed. - In the
worker: anything that needs an environment — a shell, files, a browser, or arbitrary outbound network. That last one is not a convenience: a fetch the brain performed would be our own server reaching wherever it was told to, and the brain holds the database and the encryption key.
The model is shown one merged list, with the worker's tools under their own names, and the host routes each call to the side that owns it. There is no prefix and no second list — but a worker tool that shares a name with an in-brain one is silently unreachable, because the base set wins the collision. Worth knowing rather than discovering.
Hands — the execution profile and the tool grant
Two separate questions, and both answer "no" until somebody says otherwise.
May this agent have hands at all? runtimeProfile, a column on the agent — none · light · browser · heavy · warm, none by default. A none agent touches neither the queue nor the cluster, and no release grants hands to an agent that did not have them: the migration that added the column backfilled nothing. The concierge has the same column on its own row — one per team — so switching its hands on costs the team one worker, not one per agent.
Which of the worker's tools may its sessions be given? The grant, in the agent's config.tools. It is a subset of the ten names the tool split assigns to the hands, and it is empty by default — which means a worker with no tools rather than a worker with all of them. A name outside those ten is refused rather than ignored. A name the image cannot serve is a different case and is not refused: a browser grant on a Light session simply produces no browser tool, and the list the worker reports back on ready is the one that actually attaches.
Three consequences a reader has to know before they go looking for a bug:
- An ordinary conversation raises nothing. Nothing is provisioned when a turn starts; the request only goes on the queue when the model actually reaches for a worker tool.
- One errand asks once. However many tools the model calls in a turn, one worker is requested, and a live session from an earlier turn is reused rather than replaced.
- The turn that raises the worker does not get to use it. A turn's toolset is assembled before the first model call, and the pod answers
tools/listseconds later — so the new tools land in the next turn. That is the shape of the pipeline, not a fault to be reported.
Whatever comes back from a worker tool is untrusted input, and it lands in the same context — the text of a fetched page, the output of a command. This matters most for the concierge, because it is the brain that can also create and archive agents.
The layered system prompt
The prompt an agent runs under is not its soul. It is assembled fresh each turn from three layers:
[ PLATFORM_BASE ] what an Agentfy agent is + the house rules (code, every agent)
[ TOOL_USE_GUIDANCE ] how to call tools at all — never per-tool schemas
[ soul || DEFAULT_SOUL ] the persona of THIS agent, verbatimThe base is code, so a product-level rule applies to every agent without anyone re-typing it into every soul; the soul stays purely the persona its author wrote.
Anything that varies per turn stays out of this. The user's local time is composed onto the turn by the tool layer's turn preamble, and the autostart tick's NO_REPLY contract is composed onto the tick message. A per-run fact written into the shared prompt leaks into every other turn.
The client sends its IANA timezone with the turn, and the preamble tells the model the user's local time. A turn that carries no usable timezone — an autostart tick, an API caller that did not send one — simply works in UTC.
Autostart
An agent can run a turn with nobody talking to it. A tick is a full turn — tools, memory, spend — not a cheap ping, and it is the one way an agent costs money with no user present.
- Interval
10..1440minutes, default60. Switching autostart on requires a non-empty prompt. The floor is there because a tick is a full model turn, and until consumption accounting exists the interval is the only thing bounding spend. It is declared once and checked on the write path, not only at the HTTP edge, so no other writer can slip under it; stored rows below it are raised, with a log line each. A development-only environment variable can lower it, because a check that costs ten minutes of waiting stops being run and starts being reasoned about. - One global sweeper, never a timer per agent. A single Redis-backed repeatable job wakes every 60 s across the whole
apifleet and selects the heartbeats that are due, so a tick fires exactly once however many instances are running — and nothing can be scheduled finer than the sweep. - A tick never interrupts a turn. A busy agent is skipped and caught by a later sweep; one turn per agent runs at a time (see One turn per agent).
lastRunAtis the scheduled slot, not the sweeper's wall clock, and a heartbeat counts as due within half a sweep period of that slot. Both together are what stop the grid from drifting and from double-firing.- Silence writes nothing. A tick whose entire reply is
NO_REPLYpersists nothing at all. The synthetic trigger message is never written either: an acting tick stores only the assistant's answer and its tool steps, badgedorigin=heartbeat. Agent.heartbeatis not the engine. That field is the verbatimHEARTBEAT.mdan imported package brought along. An agent can carry that text with autostart off, and have autostart on with no packaged text — an indicator built on the field would be wrong in both directions. The engine's state travels separately, asautostarton the agent.- An archived or disabled agent is never woken. The sweeper asks the same question the brain will ask a moment later — is this agent
active? — because a tick fired at anything else takes the turn lock, advances the clock and is then refused, forever, producing nothing. Taking an agent out of the archive makes it visible to the sweeper again with its schedule untouched. - A
conciergehas no autostart: its type does not grant it.
What the agent may change about its own autostart
The agent can edit its autostart from inside a turn. Where the line falls is not about which field is named — it is about who started the turn:
IN A TURN A PERSON OPENED the text + interval + on/off
IN THE AGENT'S OWN TICK the text only
NOBODY, EVER below the floor, above the ceilingThe danger was never the edit; it was the loop. An agent that may re-time itself on every wake-up decides how often it decides, with no person present at any point. When the request arrives in a conversation the person is present: they read the answer and the Heartbeat screen shows the new value the same second — that is the confirmation, without a queue for anyone to empty. A turn that carries no explicit mark is read as a tick, never as a person.
Everything the agent writes goes through the same service a human write does, so the 10-minute floor holds on this path too, and there is no second way in. Switching itself off is allowed but must be said out loud in the answer: an agent that goes quiet without saying so is a state nobody ordered, visible only as an unticked box on a screen.
Waking up faster for a while
An agent given a job that needs watching can raise its own cadence for a bounded time, then go back to normal without anyone remembering to put it back.
- The faster cadence is stored beside the ordinary one, never over it, with the instant it stops applying. What is in force is computed from the clock on every read.
- So the return needs no actor: nothing is scheduled, nothing is remembered, and it survives a restart of
apifor the same reason — a process that is not running cannot fail to do something nobody has to do. - At most 60 minutes per boost, and at most 3 boosts in 24 hours. A boost is "while I deal with this", not a new default; a standing change of cadence is the ordinary edit above.
- The floor still applies. A boost may not go below 10 minutes either. "Remind me in a minute" is exactly what the floor forbids, and giving a tool a way round it would repeal the decision rather than implement one.
Instructions that run once
A line in the autostart text marked as one-off is struck out after it has been carried out, so the next tick does not repeat it. A standing instruction is not struck out. When the last one-off line goes, the agent returns to its ordinary cadence — and stays switched on: an agent that switched itself off would stop being swept at all, and nothing outside would say why.
Two kinds of "lifecycle" — don't confuse them
- Agent lifecycle = config state (
status:active→disabled→archived). Persistent, in the DB. This is "does this agent exist / is it enabled". - Runtime session lifecycle = execution (
AgentRuntimeSession:pending → running → idle → stopped). Ephemeral, lives only while a task runs. See Worker.
The agent can be active for months while having zero runtime sessions most of the time — that's the whole point.
Channels — how messages reach the agent
channel binds an agent to a surface (telegram / slack / web widget). Inbound messages hit the ingress, which resolves the target agent + session and hands the turn to the orchestrator. The same agent (one DB row) can be bound to multiple channels at once.
Where the code lives
- Definition & domain:
api→agentgroup (agent·memory·knowledge·chat·app·channel·orchestrator). - Brain:
agent/orchestratorruns inside theapiimage, ephemerally, per turn. - Hands: the top-level
workerapp for heavy tools. - Knowledge engine: the separate LightRAG service.