---
name: arkiv-agent-state
description: Store AI agent state on Arkiv — the queryable database on Ethereum — as wallet-owned, expiring, queryable entities. Use when building what builders call "agent memory" (the precise term is ephemeral coordination state), session/run state, recall and retrieval over @arkiv-network/sdk entities, AI decision audit trails and execution traces, human-in-the-loop validate-before-write flows, AI-scored assets, multi-agent leases and handoffs, or verifying writes through the Arkiv MCP. Trigger keywords — Arkiv, @arkiv-network/sdk, Arkiv agent memory, on-chain agent state, coordination state, recall over entities, working/episodic/semantic tiers, cite-to-extend, on-chain audit trail, verifiable execution trace, tool-call log on Arkiv, runId, sessionId, lease, handoff, Braga, arkiv_* MCP tools. Do NOT activate for generic/local memory stores (Redis, vector DBs, files) with no Arkiv/on-chain intent. This skill DECIDES defaults — tier durations (working 1h / episodic 7d / semantic 365d cap), entity schemas, reinforcement via extendEntity — distilled from 124 real hackathon submissions.
license: MIT
metadata:
  arkiv.skill-version: "1.1.0"
  arkiv.sdk-package: "@arkiv-network/sdk"
  arkiv.sdk-range: ">=0.6.5 <0.7.0"
  arkiv.sdk-verified: "0.6.8"
  arkiv.network-fingerprint: "braga:60138453102"
  arkiv.surface: "createPublicClient, createWalletClient, http, privateKeyToAccount, braga, eq, gte, jsonToPayload, ExpirationTime, createEntity, updateEntity, deleteEntity, extendEntity, mutateEntities, changeOwnership, buildQuery, getEntity, NoEntityFoundError, Entity.toJson"
  arkiv.last-verified-at: "2026-06-11"
  arkiv.evidence: "4 patterns from 124 hackathon submissions across 5 events"
---

# arkiv-agent-state

Use this skill when an AI agent needs state that outlives a single process: memories, session context, execution traces, scores, locks. Arkiv is a queryable database on Ethereum where every record (entity) is owned by a wallet, carries filterable attributes, and has an expiration date (a.k.a. TTL). That combination is exactly what agent state needs: **wallet-owned** (portable across agent frameworks and vendors — "switch agents and the context follows"), **queryable** (retrieval is attribute queries, no vector DB needed for most builds), and **expiring** (forgetting is free; remembering costs intent). The precise term for this vertical is **ephemeral coordination state** — builders colloquially call it "agent memory", and that's fine in conversation, but don't architect it as a permanent archive: there is no permanence primitive on Arkiv; huge expirations are a workaround, and this skill says so honestly where it matters.

## Ground rules (read before writing code)

- **Network facts are MUTABLE. Networks rotate.** Verify at https://docs.arkiv.network before relying on these. **Testnet entities do NOT migrate between networks.** Current as of 2026-06-11:
  - Testnet: **Braga** · chainId `60138453102` · gas token GLM
  - RPC `https://braga.hoodi.arkiv.network/rpc` · Faucet `https://braga.hoodi.arkiv.network/faucet/` · Explorer `https://explorer.braga.hoodi.arkiv.network`
- **SDK:** `@arkiv-network/sdk` `>=0.6.5 <0.7.0` (verified against 0.6.8). The SDK re-exports all of viem — never install viem separately.
- **Node 22 LTS, pinned.** Node 24 silently hangs entity updates — the tx lands on-chain but the promise never resolves (arkiv-sdk-js issue #14).
- **Entities are PUBLIC.** Anyone can read your agent's state. Never put secrets, keys, or raw PII in attributes or plaintext payloads — see `arkiv-privacy-patterns` for encrypted-payload memory.
- **Two clients, always.** The wallet client has NO Arkiv read actions (`getEntity`/`buildQuery` don't exist on it). Reads → public client; writes → wallet client.
- **NEVER use `.orderBy()`** — the server silently ignores it and it is removed in SDK 0.7.0 (PR #70). Sort in JS after fetching.

## Setup: clients + namespace (every pattern below assumes this module)

```ts
// lib/arkiv.ts
import { createPublicClient, createWalletClient, http } from "@arkiv-network/sdk"
import type { Entity } from "@arkiv-network/sdk"
import { privateKeyToAccount } from "@arkiv-network/sdk/accounts"
import { braga } from "@arkiv-network/sdk/chains"

export const PROJECT = "myagent-v1" // unique versioned slug — the testnet is one SHARED store;
                                    // stamp this on EVERY write and filter it in EVERY query.

export const publicClient = createPublicClient({ chain: braga, transport: http() })
export const walletClient = createWalletClient({
  chain: braga,
  transport: http(),
  account: privateKeyToAccount(process.env.ARKIV_PRIVATE_KEY as `0x${string}`), // throwaway testnet key, env only
})
// STOP before the first write: this wallet must hold testnet GLM or every
// createEntity reverts with an insufficient-funds error. Ask the user to fund
// the address at the faucet (https://braga.hoodi.arkiv.network/faucet/ — verify
// the live URL at docs.arkiv.network), then continue.

// Attribute values interpolated into queries are quoted but NOT escaped — strip quote chars (injection defense):
export const sanitize = (s: string) => s.replace(/["\\\n\r]/g, "").slice(0, 64)
// Read one attribute off an entity:
export const attr = (e: Entity, k: string) => e.attributes.find(a => a.key === k)?.value
```

**ID conventions** (used by all schemas below): `agentId` = a stable slug or the agent's lowercased wallet address · `sessionId` = one per conversation/job (UUID) · `runId` = one per invocation inside a session (UUID, or `${sessionId}:${n}`). All three are string attributes; all timestamps are **numeric epoch-ms** attributes so they range-query (`gte("createdAt", since)`).

## Patterns

### wallet-owned tiered agent state with expiration-as-forgetting

The dominant AI pattern on Arkiv. Map cognitive tiers to expiration durations and let expiry BE the forgetting mechanism — no cleanup jobs, no deletion code. Defaults (don't bikeshed these; they're the field-tested medians — Cortex ran working ~1h → episodic ~7d → semantic ≤1y; MemoryForge ran run=1h / entity=7d / global=365d):

| Tier | Scope | Duration | Promotion trigger |
|---|---|---|---|
| `working` | current run | 1 hour | cited once → `episodic` |
| `episodic` | session | 7 days | cited ≥3× or importance ≥80 → `semantic` |
| `semantic` | agent, global | 365 days (CAP — re-extend on use; never call it permanent) | — |

Rule from Arkmy: **derived data outlives its source** — an insight extracted from a session gets ≥2× the session's expiration; the raw transcript can die first.

Entity schemas (decided, not optional — adjust attribute names only if you must):

| type | key attributes (all + `project`) | payload | expiresIn |
|---|---|---|---|
| `session` | `agentId`, `sessionId`, `status`, `startedAt` (num) | session metadata JSON | 7 d |
| `message` | `sessionId`, `runId`, `role`, `messageIndex` (num), `createdAt` (num) | full message JSON | 7 d |
| `memory` | `tier`, `topic`, `agentId`, `importance` (num), `citationCount` (num), `createdAt` (num) | extracted insight/state JSON | tier table |
| `snapshot` | `sessionId`, `seq` (num), `payloadHash`, `createdAt` (num) | full state dump | 1–7 d |

Session + message writes follow the same shape — the one non-obvious decision is **`messageIndex` as a NUMBER**, so a session transcript can be range-scanned and ordered client-side (Arkmy's idiom):

```ts
// imports: jsonToPayload, ExpirationTime from "@arkiv-network/sdk/utils"; eq, gte from "@arkiv-network/sdk/query"
export async function appendMessage(sessionId: string, runId: string, role: string, index: number, msg: unknown) {
  return walletClient.createEntity({
    payload: jsonToPayload(msg),
    contentType: "application/json",
    expiresIn: ExpirationTime.fromDays(7),
    attributes: [
      { key: "project", value: PROJECT }, { key: "type", value: "message" },
      { key: "sessionId", value: sessionId }, { key: "runId", value: runId },
      { key: "role", value: role }, { key: "messageIndex", value: index },
      { key: "createdAt", value: Date.now() },
    ],
  })
}
// A turn writes SEVERAL messages/tool-logs at once — write them in ONE tx, not a
// loop of appendMessage (looping single creates collides on the nonce, is slower,
// non-atomic). mutateEntities writes up to 1000 ops per tx (one signature, one
// nonce); 1000 is the TOTAL cap across creates+updates+deletes+extensions combined.
export async function appendMessagesBatch(
  sessionId: string, runId: string,
  msgs: { role: string; index: number; msg: unknown }[],
) {
  const creates = msgs.map(({ role, index, msg }) => ({
    payload: jsonToPayload(msg),
    contentType: "application/json",
    expiresIn: ExpirationTime.fromDays(7),
    attributes: [
      { key: "project", value: PROJECT }, { key: "type", value: "message" },
      { key: "sessionId", value: sessionId }, { key: "runId", value: runId },
      { key: "role", value: role }, { key: "messageIndex", value: index },
      { key: "createdAt", value: Date.now() },
    ],
  }))
  const out = []
  for (let i = 0; i < creates.length; i += 1000)        // chunk sets larger than the 1000-op cap
    out.push(await walletClient.mutateEntities({ creates: creates.slice(i, i + 1000) }))
  return out
}
// Resume a session: fetch messages newer than what you have, sort by messageIndex in JS
const r = await publicClient.buildQuery()
  .where([eq("project", PROJECT), eq("type", "message"), eq("sessionId", sanitize(sessionId)), gte("messageIndex", lastSeen + 1)])
  .withPayload(true).withAttributes(true).limit(200).fetch()
const newMessages = [...r.entities].sort((a, b) => Number(attr(a, "messageIndex")) - Number(attr(b, "messageIndex")))
```

```ts
// lib/memory.ts
import { jsonToPayload, ExpirationTime } from "@arkiv-network/sdk/utils"
import { walletClient, publicClient, PROJECT, sanitize, attr } from "./arkiv"

const TIER = {
  working:  ExpirationTime.fromHours(1),
  episodic: ExpirationTime.fromDays(7),
  semantic: ExpirationTime.fromDays(365),
} as const

export async function remember(m: {
  tier: keyof typeof TIER; topic: string; content: unknown;
  agentId: string; sessionId: string; runId: string; importance?: number;
}) {
  const { entityKey, txHash } = await walletClient.createEntity({
    payload: jsonToPayload({ content: m.content, storedAt: new Date().toISOString() }),
    contentType: "application/json",            // contentType is REQUIRED on create/update
    expiresIn: TIER[m.tier],                    // expiresIn is SECONDS — always via ExpirationTime
    attributes: [
      { key: "project", value: PROJECT },
      { key: "type", value: "memory" },
      { key: "tier", value: m.tier },
      { key: "topic", value: sanitize(m.topic) },
      { key: "agentId", value: m.agentId.toLowerCase() },
      { key: "sessionId", value: m.sessionId },
      { key: "runId", value: m.runId },
      { key: "importance", value: m.importance ?? 50 }, // number → range-queryable
      { key: "citationCount", value: 1 },               // start at 1: numeric 0 encodes as "" on the wire
      { key: "createdAt", value: Date.now() },
    ],
  })
  console.log(`memory ${entityKey} → https://explorer.braga.hoodi.arkiv.network/tx/${txHash}`)
  return entityKey
}
```

Retrieval is one attribute query — the `recall(topic)` helper:

```ts
import { eq, gte } from "@arkiv-network/sdk/query"

export async function recall(topic: string, o?: { tier?: string; minImportance?: number; max?: number }) {
  const preds = [
    eq("project", PROJECT), eq("type", "memory"),
    eq("topic", sanitize(topic)),
    gte("importance", o?.minImportance ?? 1),
  ]
  if (o?.tier) preds.push(eq("tier", o.tier))
  const result = await publicClient.buildQuery()   // FRESH builder per query — fetch() mutates it
    .where(preds)
    .withPayload(true).withAttributes(true)        // builder defaults are EMPTY; without these, toJson() throws
    .limit(o?.max ?? 50)                           // pagination requires .limit()
    .fetch()
  return [...result.entities]                      // no server-side ordering exists — sort in JS:
    .sort((a, b) => Number(attr(b, "importance") ?? 0) - Number(attr(a, "importance") ?? 0))
    .map(e => ({ entityKey: e.key, tier: attr(e, "tier"), ...e.toJson() }))
}
```

Reinforcement — **cite-to-extend** (use-it-or-lose-it). Each citation persists the bumped counter AND refreshes the lease in ONE full-replace update — `extendEntity` alone can't change attributes, so an extend-only path would freeze the counter at 1 and promotion would never fire:

```ts
import { NoEntityFoundError } from "@arkiv-network/sdk"

export async function onCitation(entityKey: `0x${string}`) {
  let e: Entity
  try { e = await publicClient.getEntity(entityKey) }      // THROWS for missing/expired — never null
  catch (err) {
    if (err instanceof NoEntityFoundError) return null      // already forgotten — that IS the design
    throw err
  }
  const citations = Number(attr(e, "citationCount") ?? 1) + 1
  const promote = citations >= 3 && attr(e, "tier") === "episodic"
  // FULL REPLACE — re-send payload, contentType, ALL attributes. updateEntity
  // resets expiresIn from now; that reset IS the reinforcement (a fresh lease
  // on every citation), and the bumped counter lands with the same write.
  await walletClient.updateEntity({
    entityKey,
    payload: e.payload!,
    contentType: e.contentType ?? "application/json",
    expiresIn: promote ? TIER.semantic : TIER.episodic,
    attributes: e.attributes.map(a =>
      a.key === "tier" && promote ? { key: a.key, value: "semantic" } :
      a.key === "citationCount" ? { key: a.key, value: citations } : a),
  })
  return { citations, promoted: promote }
}
```

Batch reinforcement (an agent citing 10 memories in one turn) goes in ONE atomic tx — one signature, one nonce: `walletClient.mutateEntities({ extensions: keys.map(entityKey => ({ entityKey, expiresIn: ExpirationTime.fromDays(1) })) })`. Note the trade-off: batch `extensions` are the cheap lease-refresh path but do NOT advance `citationCount` (extensions can't touch attributes) — use them for bulk keep-alive, and route promotion-relevant citations through `onCitation`.

Seen in 23 of 124 submissions — Cortex, MemoryForge, Mnemos, holomem, SealVault, Arkmy. Cortex's cite-to-extend decay is the field's best "expiration as a feature" demo.

### AI decision audit trail / execution traces

Record every tool call / decision / routing step as its own small entity, so a run is replayable and auditable by anyone from the explorer — "a queryable execution graph, not app logs" (Nomos). Traces are evidence, not archives: default 30-day expiration; keep compliance-grade decisions longer (see `arkiv-expiration-design`'s credential-outlives-operation pattern).

```ts
import { createHash } from "node:crypto"

export async function logToolCall(t: {
  agentId: string; runId: string; action: string;
  severity: "info" | "warn" | "error"; input: unknown; output: unknown;
}) {
  const body = { input: t.input, output: t.output }
  return walletClient.createEntity({
    payload: jsonToPayload(body),
    contentType: "application/json",
    expiresIn: ExpirationTime.fromDays(30),
    attributes: [
      { key: "project", value: PROJECT },
      { key: "type", value: "audit" },
      { key: "agentId", value: t.agentId },
      { key: "runId", value: t.runId },
      { key: "action", value: sanitize(t.action) },
      { key: "severity", value: t.severity },
      { key: "timestamp", value: Date.now() },
      { key: "payloadHash", value: createHash("sha256").update(JSON.stringify(body)).digest("hex") },
    ],
  })
}

// Replay a run (anyone can — public client, no key):
export async function getTrace(runId: string) {
  const r = await publicClient.buildQuery()
    .where([eq("project", PROJECT), eq("type", "audit"), eq("runId", sanitize(runId))])
    .withPayload(true).withAttributes(true).limit(200).fetch()
  return [...r.entities].sort((a, b) => Number(attr(a, "timestamp")) - Number(attr(b, "timestamp")))
}
```

**"Show your work" demo checklist** (this wins judging): every write surfaces its `txHash` + explorer URL; the README links one live `getTrace(runId)` query; the `$creator` of every audit entity is provably the agent's wallet (immutable attribution — see `arkiv-write-safety` for `$owner` vs `$creator`).

Seen in 13 of 124 submissions — ArkivLog (productized as a one-line `arkivlog.record()` npm SDK), Nomos Data, AeroTrack, ArkivGate, MemoryForge.

### validate-before-write (human-in-the-loop, both directions)

The Arkiv write is the COMMIT at the end of an off-chain validation pipeline — entities are public and writes cost gas, so garbage must never reach the chain. Two directions, both real:

- **Human gates the AI** (RecallKit): agent suggestions become local drafts → user reviews diffs → only approved drafts are signed and written. The agent "cannot save anything permanently by itself".
- **AI gates the human** (ClearShield, ViArkiv): a model validates/scores every user submission BEFORE storage; only then write.

Record the verification itself as attributes, and commit approved drafts atomically:

```ts
export async function commitDrafts(approved: { content: unknown; confidence: number; attrs: { key: string; value: string | number }[] }[]) {
  return walletClient.mutateEntities({       // one signature, one nonce, all-or-nothing
    creates: approved.map(d => ({
      payload: jsonToPayload(d.content),
      contentType: "application/json",
      expiresIn: ExpirationTime.fromDays(7),
      attributes: [
        { key: "project", value: PROJECT },
        { key: "type", value: "memory" },
        { key: "verificationStatus", value: "approved" },
        { key: "confidence", value: Math.round(d.confidence * 100) }, // integer 0–100 → range-filterable
        ...d.attrs,
      ],
    })),
  })
}
```

Query `gte("confidence", 80)` + `eq("verificationStatus", "approved")` to read only trusted state. Seen in 6 of 124 submissions — RecallKit, ClearShield, ViArkiv, Melina.

### AI-scored assets with filterable score attributes

An evaluator scores stored entities (dataset quality, viability, risk) and writes the score back as a **numeric attribute**, so curation UIs query `gte("score", 80)` directly on Arkiv instead of re-running the model. Scores are integers only — scale floats (`Math.round(x * 100)`) and document the scale.

```ts
// Evaluator loop: query unscored → run model → write score back.
const e = await publicClient.getEntity(entityKey)
await walletClient.updateEntity({              // FULL REPLACE: payload + contentType + ALL attributes again
  entityKey,
  payload: e.payload!,
  contentType: e.contentType ?? "application/json",
  expiresIn: ExpirationTime.fromDays(30),      // updateEntity RESETS expiration from now (extend ADDS instead)
  attributes: [
    ...e.attributes.filter(a => a.key !== "score"),
    { key: "score", value: Math.round(confidence * 100) },
  ],
})
```

Seen in 2 of 124 submissions — QuantumAiVault.

### multi-agent coordination: leases, handoff, private/public split

**Lease = expiration as the lock.** A worker claims a task by creating a short-lived lease entity; if the worker dies, the lease expires and the task frees itself — no unlock code path, no stuck locks:

```ts
// Claim (check first: a free task has NO live lease — absence is the signal):
const taken = await publicClient.buildQuery()
  .where([eq("project", PROJECT), eq("type", "lease"), eq("taskKey", taskKey)]) // taskKey = entityKey hex — injection-safe by construction; sanitize() anything user-shaped
  .withAttributes(true).limit(1).fetch()
if (taken.entities.length > 0) return null     // someone holds it
const lease = await walletClient.createEntity({
  payload: jsonToPayload({ taskKey, holder: AGENT_ID }),
  contentType: "application/json",
  expiresIn: ExpirationTime.fromMinutes(5),
  attributes: [
    { key: "project", value: PROJECT }, { key: "type", value: "lease" },
    { key: "taskKey", value: taskKey }, { key: "holder", value: AGENT_ID },
  ],
})
// Heartbeat while working (ADDS 5 min each time):
await walletClient.extendEntity({ entityKey: lease.entityKey, expiresIn: ExpirationTime.fromMinutes(5) })
// Early release: await walletClient.deleteEntity({ entityKey: lease.entityKey })
```

Honest caveat: query-then-create is not atomic — two agents can race in the same block. Acceptable at hackathon scale; for stricter exclusivity, deterministically assign tasks per agent or arbitrate by lowest `$creator` address among duplicate leases.

**Handoff:** give a memory to another agent's wallet with `walletClient.changeOwnership({ entityKey, newOwner })` — the receiver gains control (`$owner`), while `$creator` immutably proves who authored it.

**Private-state / public-log split** (Fhedin): encrypted 30-day memory entities (payload encrypted client-side, a numeric `confidential: 1` marker attribute) alongside a plaintext 7-day decision log others can audit. Encryption recipes live in `arkiv-privacy-patterns`.

### verify with the Arkiv MCP — the SDK writes, the MCP proves

While developing, point any MCP client (Claude, Cursor, Codex) at the hosted Arkiv MCP and ask it to inspect what your code actually wrote — independent verification beats trusting your own logs:

- Remote MCP endpoint: `https://arkivmcp.vercel.app/api/mcp` — exposes 14 `arkiv_*` tools (create/query/get/update/delete/extend/count…) to ANY MCP client. Also installable as a Claude Desktop `.mcpb`.
- The connector URL takes `?private_key=0x…` — **THROWAWAY Braga testnet key ONLY, never real funds**: the key rides in a URL.
- Dev loop: run your agent → ask the MCP "query type=audit, runId=…, project=myagent-v1 on Braga and show the payloads" → confirm schema, expirations, and counts match intent.

## Anti-patterns & gotchas (encode these BEFORE you hit them)

| # | Gotcha | Rule |
|---|---|---|
| 1 | `updateEntity` is **FULL REPLACE** — partial updates silently DROP your other attributes (including `project`!) | Always read → merge → re-send payload + contentType + ALL attributes + expiresIn |
| 2 | `expiresIn` is **SECONDS**; `expiresIn: 0` reverts on-node (`ZeroBtl`); negative values throw cryptic viem `toHex` errors; `extendEntity` **ADDS** | Use `ExpirationTime.fromDays(…)` helpers everywhere; never legacy "BTL"/`numberOfBlocks` |
| 3 | `.orderBy()` is **silently ignored** by the server and removed in 0.7.0 | Sort in JS after fetching (numeric attributes make this cheap) |
| 4 | `result.next()` throws `NoCursorOrLimitError` without `.limit(n)`; `.count()` is broken (returns one page's length) | Always `.limit()`; count via pagination loop or marker entities |
| 5 | Wallet client has **NO read actions** (`getEntity`/`buildQuery` absent) | Two clients: public for reads, wallet for writes |
| 6 | **Node 24 hangs forever on updates** — tx succeeds on-chain, promise never resolves (issue #14) | Pin Node 22 LTS (`"engines": { "node": "22.x" }`) |
| 7 | Builder data defaults are **EMPTY** — you get keys only; `toText()`/`toJson()` then throw | `.withPayload(true).withAttributes(true)` on every content query |
| 8 | `fetch()` **mutates the builder** (stores the cursor) — a second `fetch()` returns page 2 | Fresh `buildQuery()` per logical query |
| 9 | `getEntity` **throws `NoEntityFoundError`** for missing/expired entities | try/catch, never null-check — and treat "expired" as the forgetting feature, not an error |
| 10 | Query values are quoted but **NOT escaped** — a `"` in user data (a topic string!) breaks/alters the query | `sanitize()` every interpolated value |
| 15 | Attribute values are `string \| number` ONLY — booleans/bigints silently dropped, no floats, numeric `0` encodes as `""` | Booleans as 0/1, floats ×10^n scaled integers, counters start at 1 |
| 20 | `contentType` is required on create/update | `"application/json"` + `jsonToPayload()` is the canonical pairing |

Also: a single agent wallet doing parallel writes collides on nonces. Default — when you already hold the set (a turn's messages, a batch of memories) — write it in ONE `mutateEntities` call (≤1000 ops per tx, chunk larger sets). Only serialize through a write queue when writes ARRIVE over time and can't be batched (`arkiv-write-safety` has the queue snippet and the `mutateEntities` sharp edges).

## Compose with

- **arkiv-expiration-design** — tier durations as a retention matrix, heartbeats, parent/child expiry alignment, expiresAtBlock → wall-clock conversion.
- **arkiv-data-modeling** — schema worksheet, entityType discriminators, foreign-keys-via-attributes for memory graphs.
- **arkiv-query-patterns** — full query syntax, cursor pagination loops, count-via-markers, injection defense in depth.
- **arkiv-write-safety** — write queue/nonce safety, `mutateEntities` sharp edges, `$owner` vs `$creator`, error catalog.
- **arkiv-privacy-patterns** — encrypted memory payloads, wrapped-key sharing between agents, what leaks anyway.
- **arkiv-realtime** — live agent dashboards: polling done well, presence heartbeats.
- **arkiv-app-architecture** — who holds the agent's key (server signer vs per-user derived wallets), deployment.
