Skip to content

Agent import & export

How an agent moves between the standalone runtime's .agent/ folder and Agentfy.ai's Postgres — both ways. This is the agent/package adapter; the same code powers the worker materialize/sync. See also .agent compatibility.

Import — .agent/ → Agentfy.ai DB

importAgent(dir, { teamId, agentId? }). The runtime agent is single-tenant; Agentfy.ai is multi-tenant, so import needs a target teamId (and an optional agentId for update). Everything is an idempotent upsert by natural keys, wrapped per-section in a transaction.

Order (parents first)

team (given) → agent → { skill, memory, secret, access } → chat

What goes where

StepSource (.agent/)→ Agentfy.aiHow
1SOUL.mdagent.soulstore body verbatim (same name, no rename)
1agent.config.jsonagent.config (verbatim)stored as-is; runtimeProfile / limits / accessStrategy read from it (runtimeProfile defaulted if absent)
1USER.md, HEARTBEAT.mdagent.user, agent.heartbeatstore verbatim (heartbeat interval comes from config)
2skills/<n>/SKILL.mdskill rows (+ assets → system/file)parse frontmatter + body
3MEMORY.md, memory/*.mdmemory (curated + daily[date])same text; daily keyed by agentId+date
3data/memory.sqliterebuilt, not imported (re-embed from markdown)
4data/secrets/<scope>.jsonsecret (names onlypendingKeys)AGNT2-74: a package carries key NAMES, never values; a legacy file's entries values are discarded and only its names kept
5data/access.json + accessStrategyaccess (ACL + strategy)map channel-user-id → Agentfy.ai user
6data/sessions/<chan>:<uid>.jsonlchat (thread + messages)events → messages in ts order
7data/usage.jsonsystem/usage (optional baseline)usually skipped (forward-looking)
7browser-state/*.jsonapp/account (optional)sensitive/ephemeral; often skipped

Session event → chat mapping

Each JSONL line { id, type, ts, data }:

event type
usermessage(role=user, text, from)
assistantmessage(role=assistant, text)
tool_calltool-call record (name, params, toolUseId)
tool_resulttool-result record (toolUseId, result)
summarycompaction/summary marker

The original event.id is stored as externalId → re-import is idempotent (no duplicates).

Cross-cutting transform rules

  • IDs: runtime UUIDs → kept as externalId; Agentfy.ai generates its own {slice}-uuid.
  • Timestamps: epoch ms → DateTime.
  • Secrets: names only (AGNT2-74). A package never carries a credential value, so the import declares the names (AgentSecret.pendingKeys) and the values are entered by hand at the destination. Until they are, the import dialog and the secrets screen say which ones are missing.
  • Derived indexes (memory.sqlite, OpenSearch, pgvector) are never transferred — they are rebuilt from the canonical markdown/json/jsonl.

Export — Agentfy.ai DB → .agent/

exportAgent(agentId, { userScope? }) — the reverse projection into a .agent/ folder (a tarball or directory). Round-trips because both sides speak the same package format.

Source (Agentfy.ai).agent/
agent.soulSOUL.md (write body as-is)
agent.config (verbatim)agent.config.json
agent.user · agent.heartbeatUSER.md · HEARTBEAT.md
memory curated · dailyMEMORY.md · memory/YYYY-MM-DD.md
skill (+ assets)skills/<name>/SKILL.md
secret (names only, no decrypt)data/secrets/{shared|agent|user-<userId>}.json
accessdata/access.json
chat threads → eventsdata/sessions/<chan>:<user>.jsonl
system/usage (optional)data/usage.json
app/account cookiesbrowser-state/<profile>.json
  • data/memory.sqlite is not written — the runtime rebuilds its FTS from the markdown on boot.
  • The result can be pulled by the runtime (via S3) or run directly — the agent has "moved".

Limits, and what gets a package refused

An import materializes the whole archive in memory before anything is read out of it, so the ceiling is a property of the upload, not of the agent it describes:

upload25 MB
entries20 000, directories included
uncompressed total128 MB — checked twice: against what the directory declares (before a byte is inflated) and against what the inflater actually produces
compressionSTORE and DEFLATE only. bzip2/lzma are refused as an unsupported method, which is a different message from "this is not a zip"

Every refusal carries a code, not a sentence. The reason rides in the error response as details.refusal and the app keeps one text per code. A reason added on the server cannot silently fall into a generic tail — a code with no text turns a test red.

Import is all-or-nothing. A refused import leaves zero rows: no half-built agent in the list. Values the database cannot hold — a NUL byte, an unpaired surrogate, a timestamp outside timestamptz — are refused at the boundary by name, instead of reaching Postgres and coming back as a 500. (A NUL is not a hostile input: ditto on a macOS folder puts AppleDouble sidecars in the archive.)

Nothing in a package can name a path outside .agent/, in either direction. An entry that would be written outside the folder is refused on import, and no value carried by the package — a userId, a skill name, a memory date — can push an entry name outside it on export.

The agent's type is not in the manifest (see Anatomy). A concierge is neither exportable nor overwritable by an upload; both directions are shut, because the importer matches an existing agent by externalId.

A package arrives as an upload or from tenant-scoped storage — never as a path on the server. There is no "read this directory" source in the HTTP contract; a path from the caller would let any authenticated member make the server walk and read any directory the process can reach.

The archive is written to be read by strangers and to be stable. Entry names carry a UNIX platform marker, so a non-ASCII skill name unpacks correctly with the stock macOS and Linux tools rather than as mojibake; entry timestamps are fixed, so two exports of an unchanged agent are the same bytes.

Idempotency keys on (teamId, externalId). The same package imported twice into one team updates the same agent; imported into two different teams it produces two independent agents that merely share an origin id.

Round-trip fidelity

  • Clean round-trip: SOUL / agent.config / MEMORY / skills / access (text + JSON), and secret names + scope.
  • Secret values are the deliberate exception (AGNT2-74): they never enter the package, so a move always ends with "enter these N credentials". See api/src/slices/agent/package/README.md for why this beat encrypting the archive under a password.
  • Sessions: lossless if the event-type mapping is complete (keep all of user/assistant/tool_call/tool_result/summary).
  • Indexes: derived everywhere → regenerated on each side, never carried in the package.

Where it runs

  • CLI / admin endpoint: agentfy import ./.agent --team <teamId> · agentfy export <agentId> -o ./out.
  • Worker (same adapter): materialize = export (hydrate .agent/ on the pod before a turn); delta-sync = import (persist turn deltas back). Reuses the runtime's diff/S3 sync.
  • Code: apiagent/package adapter.