# Give your AI agent persistent memory

> Store, retrieve, and delete AI agent state using the VTA's per-context key/value memory store. Access is governed by the same DID-based ACL as signing and vault, and every read and write is audited.

By the end of this guide, your AI agent persists state across sessions using the VTA’s memory store.

Without a dedicated memory store, an AI agent has two options: hold state in local process memory, which is lost on restart, or run a separate database with its own infrastructure and access control. The VTA memory store is scoped to a context: the same DID-based access control list (ACL) that governs signing and vault access also controls which agents can read or write memory, so no separate database is required.

Use this guide when:

- Your AI agent needs to persist state across sessions or restarts without managing a separate database.

- You want memory access controlled by the same DID-based ACL as your other VTA operations.

- You want to isolate memory between agents by running them under separate contexts.

Not for: high-volume telemetry or large binary payloads. Use a purpose-built database for those cases.

Part 1 (Steps 1–2) runs once to create a context and provision an app identity. Part 2 (Steps 3–5) is the agent runtime: authenticate, read and write entries, and delete entries that are no longer needed. Once Part 1 is done, every session follows the flow below:

## How entries work

- Entries are identified by a (contextId, key) pair. All agents enrolled in the same context share its namespace.

- If multiple agents share a context, use a naming convention such as agent:<name>:<property> to avoid key collisions.

- If strict isolation is required, give each agent its own context instead.

- Values are plain strings. Serialise structured data to JSON before storing.

### Limits and semantics

The memory store deliberately imposes very little, so the constraints that matter are the ones you set yourself:

| Property | Behaviour |
| Value size | No per-entry limit. A single write is bounded by the VTA’s 1 MB request-body cap, which covers the whole request. |
| Number of entries | No limit per context or per VTA. |
| Expiry | None. An entry persists until something deletes it. |
| Repeated writes to one key | memory_put replaces the value rather than appending, so the last write wins. |
| Concurrent writers | Not coordinated. Two agents writing the same key in the same context overwrite each other, with no error on either side. |

Concurrency is the property to design around, and key naming is what settles it. Give each agent its own context, or prefix each agent’s keys, so two agents always write to separate keys.

Entries are durable user data and are included in a VTA backup. See [Back up and restore VTA state](/products/affinidi-elements/vta/vta-management/backup-and-restore.md).

In the example throughout this guide, a document-review agent processes documents in batches. After each batch, it writes two entries to VTA:

- workflow-state: a JSON string recording the current batch number and a brief review summary.

- last-user: the DID of the user who triggered the run.

If the process restarts mid-workflow, the next session calls memory_list, reads workflow-state, and resumes from the saved batch. When the full review completes, the agent deletes last-user so the next run starts clean.

Memory isn’t limited to resuming interrupted work. It also suits per-user personalisation. An agent can store a user’s preferred language or response format under a key like user:<did>:preferences, then read it back at the start of every future conversation with that user.

## Prerequisites

- 
A running VTA. See [Quickstart](/products/affinidi-elements/vta/get-started.md).

- 
[Install Rust 1.95](https://www.rust-lang.org/tools/install) or later.

- 
pnm CLI installed and connected to your VTA.

```bash
cargo install pnm-cli@0.16.4 --locked --registry crates-io
```

- 
Super-admin access to the VTA (required for pnm contexts create in Step 1). If the context already exists, you can skip Step 1 and do not need super-admin access.

## Step 1: Create a context

A context groups a set of VTA-held keys with an access-control list. Memory entries are scoped to a context, so each agent only has access to the memory in contexts it is enrolled in. See [Contexts](/products/affinidi-elements/vta/concepts/keys-and-contexts.md#contexts) for how context isolation works across a VTA.

```bash
pnm contexts create \
    --id    my-agent \
    --name  "My Agent"
```

If the context already exists, skip this step.

## Step 2: Provision an app identity

The bootstrap protocol creates an app DID, registers it in the context ACL, and delivers the credential securely using [sealed transfer](/products/affinidi-elements/vta/concepts/sealed-transfer.md), the same mechanism every credential-bearing VTA operation uses.

```bash
# Generate a bootstrap request (stores the X25519 private key locally).
pnm bootstrap request --out request.json

# Create the app DID, register it in the my-agent context, and seal the credential.
# The armored bundle goes to bundle.txt; the SHA-256 digest prints to
# stderr, so it stays visible in your terminal. Copy it for the next command.
pnm auth-credential create \
    --role      application \
    --contexts  my-agent \
    --label     "my-agent-memory" \
    --recipient request.json > bundle.txt

# --expect-digest verifies the bundle has not been substituted in transit.
pnm bootstrap open \
    --bundle        bundle.txt \
    --expect-digest  \
    --out           credential.json
```

The application role grants read and write access to memory in the context, but no access to key management, ACL changes, or context administration.

Store credential.json as a secret in your secrets manager. The bundle and request.json are single-use and can be deleted.
Secret hygiene

credential.json carries the agent’s private key. Never commit it or bake it into an image: inject it from your secrets manager at runtime, and delete bundle.txt and request.json once the credential is stored.

The export VTA_CREDENTIAL=... form used in the example below is convenient for a local run, and it also places the key in your shell history and in the process environment. In production, read the credential from your secrets manager inside the process instead.

Confirm the credential is not echoed in logs or debug output. Rotate it through [Rotate an application credential](/products/affinidi-elements/vta/vta-management/rotate-application-credential.md) if it is ever exposed.

## Step 3: Authenticate at runtime

The credential from Step 2 is the agent’s identity on the VTA. Load it at startup to establish an authenticated session. Because it was registered with the application role in the my-agent context, the session automatically has read and write access to that context’s memory store. VtaClient::connect_auto checks whether a mediator is configured. If not, it connects over REST. If one is configured, it connects over DIDComm through that mediator.

```rust
use vta_sdk::client::{AutoConnect, VtaClient};
use vta_sdk::credentials::CredentialBundle;

let cred_json = std::env::var("VTA_CREDENTIAL").map_err(|_| "VTA_CREDENTIAL not set")?;
let cred: CredentialBundle = serde_json::from_str(&cred_json)?;
// Set VTA_MEDIATOR_DID to connect over DIDComm; leave it unset for REST.
let mediator_did = std::env::var("VTA_MEDIATOR_DID").ok();

let connected = VtaClient::connect_auto(AutoConnect {
    vta_url: cred.vta_url.as_deref().unwrap_or_default(),
    vta_did: &cred.vta_did,
    credential_did: &cred.did,
    private_key_multibase: &cred.private_key_multibase,
    mediator_did: mediator_did.as_deref(),
})
.await?;
let client = connected.client;
let context_id = std::env::var("CONTEXT_ID").map_err(|_| "CONTEXT_ID not set")?;
```

When your agent’s session ends, call client.shutdown().await. It closes a DIDComm session cleanly and is a no-op over REST, so it’s safe to call either way.
Note

Wrap your fallible calls in a block first, so shutdown still runs if an early ? returns an error. Dropping a DIDComm client without calling shutdown panics in debug builds. See the full example below.

## Step 4: Read and write entries

memory_put upserts: calling it with an existing key replaces the stored value. Write entries at natural checkpoints: after each item, stage, or recorded observation. A restart always finds the last known state. If more than one agent shares this context, prefix keys with the agent’s name (see [How entries work](#how-entries-work)) so their writes don’t collide.

```rust
use serde_json::json;

// Store a plain string entry.
client
    .memory_put(&context_id, "last-user", "did:key:z6Mk...")
    .await?;

// Store structured data by serializing to a JSON string first.
let state = json!({ "step": 3, "summary": "reviewed documents" });
let state_str = state.to_string();
client
    .memory_put(&context_id, "workflow-state", &state_str)
    .await?;
```

At the start of a new session, call memory_list to read back whatever the previous session wrote. Items are returned in ascending key order.

```rust
let result = client.memory_list(&context_id).await?;
if let Some(items) = result["items"].as_array() {
    for item in items {
        println!("{}: {}", item["key"], item["value"]);
    }
}
```

## Step 5: Delete an entry

memory_delete removes one entry by key, and fails with not_found if the key doesn’t exist. Use it to clear a checkpoint once a workflow completes, so the next run starts clean rather than resuming a finished task. If your workflow might call delete more than once for the same key, catch not_found and treat it as already-clean rather than an error.

```rust
client.memory_delete(&context_id, "last-user").await?;
```

## Full example

The example below puts the full session lifecycle into one runnable service: it writes a progress checkpoint and structured workflow state to VTA, lists all stored entries to confirm persistence, then deletes one entry to show selective cleanup. Adapt the key names and values to match your agent’s actual state model.

Create a new project with the following Cargo.toml and src/main.rs:

[package]
name = "agent-memory-example"
version = "0.1.0"
edition = "2024"

[dependencies]
vta-sdk    = { version = "0.34.0", features = ["session", "acl-setup"] }
tokio      = { version = "1", features = ["macros", "rt-multi-thread"] }
serde_json = "1"

use serde_json::json;
use vta_sdk::client::{AutoConnect, VtaClient};
use vta_sdk::credentials::CredentialBundle;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let cred_json = std::env::var("VTA_CREDENTIAL").map_err(|_| "VTA_CREDENTIAL not set")?;
    let cred: CredentialBundle = serde_json::from_str(&cred_json)?;
    // Set VTA_MEDIATOR_DID to connect over DIDComm; leave it unset for REST.
    let mediator_did = std::env::var("VTA_MEDIATOR_DID").ok();

    let connected = VtaClient::connect_auto(AutoConnect {
        vta_url: cred.vta_url.as_deref().unwrap_or_default(),
        vta_did: &cred.vta_did,
        credential_did: &cred.did,
        private_key_multibase: &cred.private_key_multibase,
        mediator_did: mediator_did.as_deref(),
    })
    .await?;
    let client = connected.client;
    let context_id = std::env::var("CONTEXT_ID").map_err(|_| "CONTEXT_ID not set")?;

    // Run the fallible work in a block so shutdown() below always runs,
    // even when an early `?` returns an error.
    let result: Result<usize, Box<dyn std::error::Error>> = async {
        // Store a plain string entry.
        client
            .memory_put(&context_id, "last-user", "did:key:z6Mk...")
            .await?;
        println!("Stored: last-user");

        // Store structured data as a JSON string.
        let state = json!({ "step": 3, "summary": "reviewed documents" });
        let state_str = state.to_string();
        client
            .memory_put(&context_id, "workflow-state", &state_str)
            .await?;
        println!("Stored: workflow-state");

        // List all entries in the context.
        let list = client.memory_list(&context_id).await?;
        println!("\nAll entries:");
        if let Some(items) = list["items"].as_array() {
            for item in items {
                println!("  {}: {}", item["key"], item["value"]);
            }
        }

        // Delete one entry.
        client.memory_delete(&context_id, "last-user").await?;
        println!("\nDeleted: last-user");

        // Confirm the remaining entry.
        let list = client.memory_list(&context_id).await?;
        Ok(list["items"].as_array().map_or(0, |v| v.len()))
    }
    .await;

    // shutdown() must run whether the block above succeeded or failed:
    // dropping a DIDComm client without it panics in debug builds.
    client.shutdown().await;

    println!("Remaining entries: {}", result?);

    Ok(())
}

In your terminal, export the credential and context before running cargo run:

```bash
export VTA_CREDENTIAL=$(cat credential.json)
export CONTEXT_ID=my-agent
# Optional: set this to exercise the DIDComm path instead of REST.
# export VTA_MEDIATOR_DID=
cargo run
```

Expected output:

```bash
Stored: last-user
Stored: workflow-state

All entries:
  last-user: did:key:z6Mk...
  workflow-state: {"step":3,"summary":"reviewed documents"}

Deleted: last-user
Remaining entries: 1
```

## Confirm

The point of this guide is state that survives a restart, so the tests below span two runs rather than one.

### Test 1: a write is readable back in the same session

After memory_put, memory_list returns the entry:

```text
workflow-state: {"step":3,"summary":"reviewed documents"}
last-user: did:key:z6Mk...
```

Items come back in ascending key order.

### Test 2: the entry survives a process restart

Stop the agent and start it again, then call memory_list before writing anything. The same two entries return. This is the behaviour that replaces a separate database: nothing was held in process memory across the restart.

### Test 3: deleting an absent key reports not_found

```rust
client.memory_delete(&context_id, "last-user").await?;   // succeeds
client.memory_delete(&context_id, "last-user").await?;   // not_found
```

The second call fails rather than silently succeeding. Treat not_found as already-clean if your workflow can run its cleanup more than once.

## Troubleshooting

| Symptom | Likely cause | Fix |
| memory_put returns context not found | The contextId does not exist on the VTA. | Confirm the ID with pnm contexts list; run Step 1 if it is missing. |
| memory_list returns empty after a successful memory_put | A different contextId string was used in the two calls. | Check that both calls use the exact same string; context IDs are case-sensitive. |
| An agent’s entries are silently overwritten by another agent | Multiple agents writing to the same key in a shared context. | Prefix keys with the agent name: agent:<name>:<property>. |
| connect_auto fails with DID not in ACL | The credential was provisioned for a different context. | Re-run Step 2 with --contexts my-agent matching the target context ID. |
| memory_delete succeeds but the entry reappears on the next memory_list | Another agent or concurrent session wrote the same key after the delete. | Coordinate writes across sessions, or give each agent its own context. |
| A memory call over DIDComm times out with no error | The acl-setup feature is missing, so the mediator never registered your agent’s DID and silently drops the reply. | Add features = ["session", "acl-setup"] to Cargo.toml. |
| connect_auto (DIDComm path) fails with DID not found | VTA_MEDIATOR_DID is incorrect or refers to a different VTA. | Run pnm services list and copy the DID from the DIDComm row. |
| connect_auto returns REST transport requires a non-empty vta_url | No mediator was supplied and the credential has no vtaUrl. | Set VTA_URL and pass it, or supply VTA_MEDIATOR_DID to use the DIDComm path instead. |
| VTA_CREDENTIAL not set or CONTEXT_ID not set | The variable isn’t set in the current shell. export only persists for that terminal session. | Re-run the export commands from Step 2 in the same terminal you run cargo run in. |

## Next steps

Your agent can now persist state across restarts using VTA as the backing store, with access controlled by the same DID-based ACL as every other VTA operation. The same context and credential open up the other capabilities below.

  [Sign application payloads without exposing your keys](/products/affinidi-elements/vta/integration-guides/provisioning-app-signing.md): use the same context and credential for remote signing.

  [Grant and revoke access](/products/affinidi-elements/vta/vta-management/acl-management.md): control which agents can read and write memory in each context.

  [Manage the contexts on a VTA](/products/affinidi-elements/vta/vta-management/manage-contexts.md): give a second agent its own context, or remove this one and the memory it holds.
