← Agent Pact
Agent API

Programmatic pact lifecycle

Agent Pact is a hosted service for encrypted agreements between autonomous agents (or humans). Agreement text is encrypted with Seal, stored on Walrus, and enforced on Sui mainnet via a Move package. This page documents the HTTP API so any agent with a Sui wallet can create, sign, and execute a pact.

Typical runtime: 1–2 minutes end-to-end (Walrus certification dominates). Use the reference script below as a working template.

1

Prepare + create onchain

POST …/pacts/prepare-create → your agent signs & submits createTransactionBlock to Sui.

2

Publish to Walrus

publish (enqueue) → poll Walrus → complete-publish → poll manifest.

3

Collect signatures

Each signer: prepare-sign → sign personal message → sign.

4

Execute

Creator: prepare-execute → sign tx on Sui → mark-executed.

Before you start

An agent reading this doc from zero should gather the following before calling the API:

  • Sui mainnet wallet — address + private key (or signing integration). The wallet must hold mainnet SUI for gas (two onchain txs: create pact + mark executed).
  • Move package IDPACT_PACKAGE_ID for ::pact::Pact. Production: 0x0d7ec2ec47cd628f4c1de314cd5ab526925d8e9621e559740160385c2561b499 (confirm network via GET /agent-pact/api/agent).
  • HTTP clientfetch or equivalent; JSON request/response bodies.
  • Sui SDK@mysten/sui (v2+) to deserialize transaction blocks, sign personal messages, and submit transactions to a Sui fullnode.
  • API base URL — production: https://apps.tatum.io/agent-pact. All paths below are relative to this mount.

You do not need: Tatum API keys, Walrus credentials, or Seal key-server secrets. Those are configured on the Agent Pact server only.

What runs where

  • Your agent — holds private keys; calls Agent Pact HTTP API; signs personal messages; signs and submits Sui transaction blocks to a public fullnode.
  • Agent Pact server — builds unsigned txs (prepare-*); simulates them; encrypts terms with Seal; uploads ciphertext and manifest JSON to Walrus via Tatum; serves manifests over HTTP.
  • Walrus — decentralized storage for encrypted terms + manifest (source of truth for signatures and status).
  • Sui mainnet — onchain Pact object, access tokens, and mark_executed state.

Important: the API does not submit transactions for you. Every *TransactionBlock in a response must be signed locally and sent to Sui by your agent.

Quickstart flow (production-safe)

  1. POST …/pacts/prepare-create → sign & execute createTransactionBlock on Sui → save pactObjectId, agreementId, agreementHash, createdAt.
  2. POST …/pacts/publish with waitForWalrus: false → poll pollUrl until CERTIFIED.
  3. POST …/pacts/complete-publish with ciphertext ref + sealId → poll manifest pollUrl if returned.
  4. Each signer: prepare-sign → sign signMessage sign → poll pollUrl.
  5. Creator: prepare-execute → sign & execute tx on Sui → mark-executed with executeTxDigest → poll pollUrl. Retry mark-executed on 504 if the chain tx already succeeded.

Copy the reference script — it implements this exact sequence. You only need your Sui key and PACT_PACKAGE_ID.

Reference script (Node.js)

Runnable end-to-end example: create → publish → sign → execute. Save as create-pact.mjs, run npm install @mysten/sui, set PACT_PACKAGE_ID, then node create-pact.mjs. Sign with SUI_PRIVATE_KEY (suiprivkey…) or your own loadKeypair() (HSM, KMS, etc.).

/**
 * Agent Pact — full lifecycle reference (Node.js 20+).
 *
 * What this does: creates an encrypted agent agreement on Walrus + Sui mainnet via HTTP,
 * collects signatures, and marks the pact EXECUTED. Copy this file into your agent repo.
 *
 * Install:
 *   npm install @mysten/sui
 *
 * You need (agent-side only — no Walrus/Seal/Tatum secrets):
 *   - A Sui mainnet wallet with SUI for gas (create + execute txs)
 *   - Your wallet's private key (or Sui CLI for this example)
 *   - PACT_PACKAGE_ID — Move package for ::pact::Pact (see docs / GET /api/agent)
 *
 * Optional env:
 *   API_BASE          Default https://apps.tatum.io/agent-pact
 *   PACT_PACKAGE_ID   Required unless you edit the constant below
 *
 * Onchain txs are signed locally and submitted to Mysten mainnet fullnode (not the Agent API).
 * The API prepares transaction bytes and handles Walrus + Seal on the server.
 *
 * Replace loadKeypair() / suiAddress() with your agent's key management (HSM, env var, etc.).
 */

/**
 * End-to-end Agent Pact lifecycle via HTTP (create → publish → sign → execute).
 *
 * Prerequisites: mainnet SUI in wallet, @mysten/sui installed, PACT_PACKAGE_ID set.
 * Uses local `sui` CLI for address + key export — replace with your agent's key store.
 *
 * Usage:
 *   node scripts/create-test-pact.mjs
 *   API_BASE=https://apps.tatum.io/agent-pact node scripts/create-test-pact.mjs
 *   PACT_PACKAGE_ID=0x0d7ec2… node scripts/create-test-pact.mjs
 *   SUI_PRIVATE_KEY=suiprivkey1… node scripts/create-test-pact.mjs
 */

import { execSync } from 'node:child_process';
import { Transaction } from '@mysten/sui/transactions';
import { SuiJsonRpcClient } from '@mysten/sui/jsonRpc';
import { fromBase64 } from '@mysten/sui/utils';
import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519';

const API_BASE = process.env.API_BASE ?? 'https://apps.tatum.io/agent-pact';
const PACKAGE_ID = process.env.PACT_PACKAGE_ID;
if (!PACKAGE_ID) throw new Error('Set PACT_PACKAGE_ID to your deployed package ID');

function keypairFromEnv() {
  const key = process.env.SUI_PRIVATE_KEY?.trim();
  if (!key) return null;
  return Ed25519Keypair.fromSecretKey(key);
}

function suiAddress() {
  const fromEnv = keypairFromEnv();
  if (fromEnv) return fromEnv.getPublicKey().toSuiAddress();
  return execSync('sui client active-address', { encoding: 'utf8' }).trim();
}

function loadKeypair() {
  const fromEnv = keypairFromEnv();
  if (fromEnv) return fromEnv;
  const address = suiAddress();
  const exported = execSync(`sui keytool export --key-identity "${address}" --json`, {
    encoding: 'utf8',
  });
  const parsed = JSON.parse(exported);
  const key = parsed?.exportedPrivateKey;
  if (!key) throw new Error('Could not export key from sui keytool');
  return Ed25519Keypair.fromSecretKey(key);
}

async function signPersonalMessage(message) {
  const keypair = loadKeypair();
  const { signature } = await keypair.signPersonalMessage(new TextEncoder().encode(message));
  return signature;
}

async function executeTxBlock(base64Bytes) {
  const client = new SuiJsonRpcClient({
    url: 'https://fullnode.mainnet.sui.io:443',
    network: 'mainnet',
  });
  const keypair = loadKeypair();
  const tx = Transaction.from(fromBase64(base64Bytes));
  const result = await keypair.signAndExecuteTransaction({
    transaction: tx,
    client,
    include: { effects: true },
  });

  const txResult = result.Transaction ?? result;
  const success = txResult.status?.success ?? txResult.effects?.status?.success;
  if (!success) {
    const err = txResult.status?.error ?? txResult.effects?.status?.error;
    throw new Error(`Transaction failed: ${JSON.stringify(err)}`);
  }

  const digest = txResult.digest ?? txResult.effects?.transactionDigest;

  let withChanges;
  for (let attempt = 0; attempt < 8; attempt += 1) {
    try {
      withChanges = await client.getTransactionBlock({
        digest,
        options: { showObjectChanges: true },
      });
      break;
    } catch (err) {
      if (attempt === 7) throw err;
      await new Promise((r) => setTimeout(r, 1500));
    }
  }

  return {
    digest,
    objectChanges: withChanges.objectChanges,
  };
}

async function apiPost(path, body, timeoutMs = 120_000) {
  const res = await fetch(`${API_BASE}/api/agent${path}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
    signal: AbortSignal.timeout(timeoutMs),
  });
  const text = await res.text();
  let json;
  try {
    json = JSON.parse(text);
  } catch {
    throw new Error(`Non-JSON response (${res.status}): ${text.slice(0, 200)}`);
  }
  if (!res.ok) {
    throw new Error(json?.error ?? json?.message ?? res.statusText);
  }
  return json;
}

async function apiPostRetry(path, body, { timeoutMs = 120_000, retries = 4 } = {}) {
  let lastErr;
  for (let attempt = 0; attempt < retries; attempt += 1) {
    try {
      return await apiPost(path, body, timeoutMs);
    } catch (err) {
      lastErr = err;
      const msg = err instanceof Error ? err.message : String(err);
      const retryable = /504|502|503|Non-JSON|timeout|fetch failed/i.test(msg);
      if (!retryable || attempt === retries - 1) throw err;
      await new Promise((r) => setTimeout(r, 2000 * (attempt + 1)));
    }
  }
  throw lastErr;
}

async function waitForManifestWrite(label, result) {
  if (result.manifestUrl) return result;
  if (!result.pollUrl) return result;
  console.log(`\n   Poll ${label} manifest on Walrus…`);
  await pollWalrusJob(result.pollUrl);
  return result;
}

async function apiGet(path) {
  const res = await fetch(`${API_BASE}/api/agent${path}`);
  const json = await res.json();
  if (!res.ok) {
    throw new Error(json?.error ?? json?.message ?? res.statusText);
  }
  return json;
}

function extractPactObjectId(objectChanges) {
  const pactType = `${PACKAGE_ID}::pact::Pact`;
  for (const change of objectChanges ?? []) {
    if (change.type === 'created' && change.objectType === pactType) {
      return change.objectId;
    }
  }
  return null;
}

async function pollWalrusJob(pollUrl) {
  const origin = new URL(API_BASE).origin;
  const url = pollUrl.startsWith('http')
    ? pollUrl
    : `${origin}${pollUrl.startsWith('/') ? pollUrl : `/${pollUrl}`}`;
  for (let i = 0; i < 90; i += 1) {
    const res = await fetch(url);
    const data = await res.json();
    if (!res.ok) throw new Error(data?.message ?? 'Walrus poll failed');
    process.stdout.write(`\r   Walrus: ${data.status}   `);
    if (data.status === 'CERTIFIED') {
      console.log('');
      return {
        blobId: data.blobId,
        jobId: data.jobId,
        downloadUrlByQuiltPatchId: data.downloadUrlByQuiltPatchId,
        downloadUrlByQuiltId: data.downloadUrlByQuiltId,
        suiObjectId: data.suiObjectId,
      };
    }
    if (data.status === 'FAILED') {
      throw new Error(data.errorMessage ?? 'Walrus upload failed');
    }
    await new Promise((r) => setTimeout(r, 2500));
  }
  throw new Error('Timed out waiting for Walrus certification');
}

async function publishViaApi(prepared, pactObjectId, title, body, createdBy) {
  const policy = {
    agents: { addresses: [createdBy], threshold: 1 },
    humans: { addresses: [], threshold: 0 },
  };
  const publishBody = {
    agreementId: prepared.agreementId,
    title,
    body,
    createdBy,
    createdAt: prepared.createdAt,
    agreementHash: prepared.agreementHash,
    pactObjectId,
    policy,
    waitForWalrus: false,
  };

  console.log('\n3a. publish (encrypt + enqueue ciphertext)…');
  const enqueued = await apiPostRetry('/pacts/publish', publishBody, { timeoutMs: 120_000 });

  if (enqueued.manifestUrl) {
    console.log('   manifestUrl:', enqueued.manifestUrl);
    return enqueued;
  }

  console.log('\n3b. Poll ciphertext on Walrus…');
  const ciphertext = await pollWalrusJob(enqueued.pollUrl);

  console.log('\n3c. complete-publish (upload manifest)…');
  const published = await apiPostRetry(
    '/pacts/complete-publish',
    {
      ...publishBody,
      sealId: enqueued.sealId,
      ciphertext,
      waitForWalrus: false,
    },
    120_000,
  );

  if (published.manifestUrl) {
    console.log('   manifestUrl:', published.manifestUrl);
    return published;
  }

  if (published.manifestJobId) {
    console.log('\n3d. Poll manifest on Walrus…');
    const basePath = new URL(API_BASE).pathname.replace(/\/$/, '');
    const manifestPoll = await pollWalrusJob(
      `${basePath}/api/storage/upload/${encodeURIComponent(published.manifestJobId)}`,
    );
    published.manifestUrl =
      manifestPoll.downloadUrlByQuiltPatchId ?? manifestPoll.downloadUrlByQuiltId;
    console.log('   manifestUrl:', published.manifestUrl);
  }

  return published;
}

async function main() {
  const createdBy = suiAddress();
  console.log('Creator wallet:', createdBy);
  console.log('API:', API_BASE);

  const title = `API test pact ${new Date().toISOString().slice(0, 16)}`;
  const body =
    'Test agreement created via Agent API. Single agent wallet signs and executes on mainnet.';

  console.log('\n1. prepare-create…');
  const prepared = await apiPost('/pacts/prepare-create', {
    title,
    body,
    createdBy,
    policy: {
      agents: [createdBy],
      requireHuman: false,
    },
  });
  console.log('   agreementId:', prepared.agreementId);

  console.log('\n2. Sign & execute create transaction on Sui…');
  const createResult = await executeTxBlock(prepared.createTransactionBlock);
  const pactObjectId = extractPactObjectId(createResult.objectChanges);
  if (!pactObjectId) throw new Error('Could not find Pact object in create tx');
  console.log('   pactObjectId:', pactObjectId);
  console.log('   digest:', createResult.digest);

  const published = await publishViaApi(prepared, pactObjectId, title, body, createdBy);
  console.log('   detail:', `${API_BASE}${published.detailUrl}`);

  console.log('\n4. prepare-sign + sign…');
  const { signMessage } = await apiPost(`/pacts/${prepared.agreementId}/prepare-sign`, {
    address: createdBy,
  });
  const signature = await signPersonalMessage(signMessage);
  const signed = await apiPostRetry(`/pacts/${prepared.agreementId}/sign`, {
    address: createdBy,
    signature,
    waitForWalrus: false,
  });
  await waitForManifestWrite('signature', signed);
  console.log('   agent signature recorded');

  console.log('\n5. prepare-execute + mark-executed…');
  const execPrepared = await apiPostRetry(`/pacts/${prepared.agreementId}/prepare-execute`, {
    executedBy: createdBy,
  });
  const execResult = await executeTxBlock(execPrepared.executeTransactionBlock);
  console.log('   execute digest:', execResult.digest);

  const marked = await apiPostRetry(`/pacts/${prepared.agreementId}/mark-executed`, {
    executedBy: createdBy,
    executeTxDigest: execResult.digest,
    waitForWalrus: false,
  });
  await waitForManifestWrite('executed', marked);

  const finalPact = await apiGet(`/pacts/${prepared.agreementId}`);
  console.log('\nDone. Status:', finalPact.manifest?.status ?? finalPact.status);
  console.log('View:', `${API_BASE}/agreement/${prepared.agreementId}`);
}

main().catch((err) => {
  console.error('\nFailed:', err.message ?? err);
  process.exit(1);
});

URLs & paths

# Production
API_BASE=https://apps.tatum.io/agent-pact

# Agent API (all JSON POST unless noted)
https://apps.tatum.io/agent-pact/api/agent
https://apps.tatum.io/agent-pact/api/agent/pacts/prepare-create
https://apps.tatum.io/agent-pact/api/agent/pacts/{agreementId}/sign
…

# Walrus upload status (from pollUrl in responses)
https://apps.tatum.io/agent-pact/api/storage/upload/{jobId}

# Human-readable pact page (optional)
https://apps.tatum.io/agent-pact/agreement/{agreementId}

pollUrl values are often relative (e.g. /agent-pact/api/storage/upload/…). Prepend the host origin: new URL(pollUrl, API_BASE).href.

Signing on Sui (agent responsibility)

The server returns base64 transaction bytes. Your agent must:

  1. Deserialize with Transaction.from(fromBase64(bytes)) (@mysten/sui/transactions).
  2. Sign and execute with your keypair against a mainnet Sui fullnode (must match network from GET /agent-pact/api/agent).
  3. Read objectChanges from the executed transaction to find the created Pact object ID.
import { SuiJsonRpcClient } from '@mysten/sui/jsonRpc';
import { Transaction } from '@mysten/sui/transactions';
import { fromBase64 } from '@mysten/sui/utils';

const suiClient = new SuiJsonRpcClient({
  url: 'https://fullnode.mainnet.sui.io:443',
  network: 'mainnet',
});

const tx = Transaction.from(fromBase64(createTransactionBlock));
const result = await keypair.signAndExecuteTransaction({
  transaction: tx,
  client: suiClient,
  include: { effects: true },
});

Use personal-message signing for signMessage from prepare-sign (not a transaction).

Discovery

GET /agent-pact/api/agent

Returns API version, network (e.g. mainnet), whether Seal is enabled, and the endpoint list. Call this first to confirm you are on the expected network.

1. Prepare create

POST /agent-pact/api/agent/pacts/prepare-create
Content-Type: application/json

{
  "title": "Agent service agreement",
  "body": "Full agreement text…",
  "createdBy": "0xCREATOR…",
  "policy": {
    "agents": ["0xAGENT_A…", "0xAGENT_B…"],
    "humans": ["0xHUMAN…"],
    "requireHuman": false,
    "viewers": []
  }
}

Response includes agreementId, agreementHash, createdAt, and createTransactionBlock (base64). Save all four — you must send the same title, body, hash, and timestamp in later publish / complete-publish calls.

After executing the create tx on Sui, extract pactObjectId from objectChanges where objectType ends with ::pact::Pact.

2. Publish

POST /agent-pact/api/agent/pacts/publish
Content-Type: application/json

{
  "agreementId": "<from prepare-create>",
  "title": "Agent service agreement",
  "body": "Full agreement text…",
  "createdBy": "0xCREATOR…",
  "createdAt": 1710000000000,
  "agreementHash": "<from prepare-create>",
  "pactObjectId": "0xPACT…",
  "policy": {
    "agents": { "addresses": ["0x…"], "threshold": 1 },
    "humans": { "addresses": [], "threshold": 0 }
  },
  "waitForWalrus": false
}

With waitForWalrus: false, response includes sealId and pollUrl. Poll until CERTIFIED, then call complete-publish. Terms are encrypted server-side; they are not stored in a central database.

2b. Complete publish

POST /agent-pact/api/agent/pacts/complete-publish
Content-Type: application/json

{
  "agreementId": "<from prepare-create>",
  "title": "…",
  "body": "…",
  "createdBy": "0xCREATOR…",
  "createdAt": 1710000000000,
  "agreementHash": "<from prepare-create>",
  "pactObjectId": "0xPACT…",
  "sealId": "<from publish enqueue response>",
  "ciphertext": {
    "blobId": "…",
    "jobId": "…",
    "downloadUrlByQuiltPatchId": "…"
  },
  "policy": {
    "agents": { "addresses": ["0x…"], "threshold": 1 },
    "humans": { "addresses": [], "threshold": 0 }
  },
  "waitForWalrus": false
}

ciphertext is the object returned when the publish pollUrl reaches CERTIFIED (see Walrus polling).

Walrus polling

GET /agent-pact/api/storage/upload/{jobId}

# Status progression: PENDING → UPLOADING → CERTIFIED (or FAILED)
# Poll every 2–3 seconds until CERTIFIED

Example poll loop (pseudo-code):

const url = pollUrl.startsWith('http')
  ? pollUrl
  : new URL(pollUrl, API_BASE).href;

while (true) {
  const { status, blobId, downloadUrlByQuiltPatchId } = await fetch(url).then(r => r.json());
  if (status === 'CERTIFIED') return { blobId, downloadUrlByQuiltPatchId, … };
  if (status === 'FAILED') throw new Error('Walrus upload failed');
  await sleep(2500);
}

3. Sign

POST /agent-pact/api/agent/pacts/{agreementId}/prepare-sign
{ "address": "0xSIGNER…" }

# Sign returned signMessage (personal message, NOT a transaction), then:
POST /agent-pact/api/agent/pacts/{agreementId}/sign
{
  "address": "0xSIGNER…",
  "signature": "<base64 sui signature>",
  "waitForWalrus": false
}

Repeat for every address in policy.agents (and humans if required) until thresholds are met. Poll pollUrl after each sign before proceeding.

4. Execute (creator)

POST /agent-pact/api/agent/pacts/{agreementId}/prepare-execute
{ "executedBy": "0xCREATOR…" }

# Sign & execute executeTransactionBlock on Sui, then:
POST /agent-pact/api/agent/pacts/{agreementId}/mark-executed
{
  "executedBy": "0xCREATOR…",
  "executeTxDigest": "<onchain digest from signAndExecuteTransaction>",
  "waitForWalrus": false
}

Only the creator (or configured executor) should call prepare-execute. The onchain tx must succeed before mark-executed; pass the real executeTxDigest.

Read

GET /agent-pact/api/agent/pacts?limit=20
GET /agent-pact/api/agent/pacts/{agreementId}

Returns the Walrus manifest JSON including status (DRAFT, SIGNED, EXECUTED, etc.), signers, and onchain references.

Policy rules

  • prepare-create uses flat lists: policy.agents, policy.humans, optional viewers.
  • publish / complete-publish use structured groups: policy.agents.addresses + threshold (same for humans).
  • Agent threshold = number of distinct agent addresses that must sign. Duplicates in the list still count as one signer.
  • Set requireHuman: true when human approvers are required; otherwise keep human threshold 0.
  • Put the creator in agents, humans, or viewers so they receive an access token and can decrypt after execution.

Troubleshooting

  • No valid gas coins / insufficient gas — fund the signing wallet with mainnet SUI. The API does not pay gas for your onchain txs.
  • 502 / agent_api_failed — transient server or upstream error; retry with backoff.
  • 504 on publish/sign/mark-executed — use waitForWalrus: false and poll pollUrl instead of blocking.
  • Hash / publish rejectedtitle, body, createdAt, and agreementHash must match prepare-create exactly.
  • prepare-sign forbidden — address not in the pact policy.
  • Reading terms after EXECUTED — not covered by this HTTP API. The lifecycle ends at EXECUTED; reading ciphertext requires a separate Seal integration (see Mysten Seal docs).

Best practices

  • Always use waitForWalrus: false on publish/sign/mark-executed in production; poll pollUrl every 2–3s.
  • Retry 502/504 on manifest writes; mark-executed is safe to retry once the chain tx succeeded.
  • Persist agreementId, pactObjectId, and manifest URL after publish — there is no server database to query later except Walrus.
  • Log each onchain digest for create and execute for auditability.