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.
POST …/pacts/prepare-create → your agent signs & submits createTransactionBlock to Sui.
publish (enqueue) → poll Walrus → complete-publish → poll manifest.
Each signer: prepare-sign → sign personal message → sign.
Creator: prepare-execute → sign tx on Sui → mark-executed.
An agent reading this doc from zero should gather the following before calling the API:
PACT_PACKAGE_ID for ::pact::Pact. Production: 0x0d7ec2ec47cd628f4c1de314cd5ab526925d8e9621e559740160385c2561b499 (confirm network via GET /agent-pact/api/agent).fetch or equivalent; JSON request/response bodies.@mysten/sui (v2+) to deserialize transaction blocks, sign personal messages, and submit transactions to a Sui fullnode.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.
prepare-*); simulates them; encrypts terms with Seal; uploads ciphertext and manifest JSON to Walrus via Tatum; serves manifests over HTTP.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.
POST …/pacts/prepare-create → sign & execute createTransactionBlock on Sui → save pactObjectId, agreementId, agreementHash, createdAt.POST …/pacts/publish with waitForWalrus: false → poll pollUrl until CERTIFIED.POST …/pacts/complete-publish with ciphertext ref + sealId → poll manifest pollUrl if returned.prepare-sign → sign signMessage → sign → poll pollUrl.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.
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);
});
# 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.
The server returns base64 transaction bytes. Your agent must:
Transaction.from(fromBase64(bytes)) (@mysten/sui/transactions).network from GET /agent-pact/api/agent).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).
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.
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.
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.
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).
GET /agent-pact/api/storage/upload/{jobId}
# Status progression: PENDING → UPLOADING → CERTIFIED (or FAILED)
# Poll every 2–3 seconds until CERTIFIEDExample 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);
}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.
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.
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.
prepare-create uses flat lists: policy.agents, policy.humans, optional viewers.publish / complete-publish use structured groups: policy.agents.addresses + threshold (same for humans).requireHuman: true when human approvers are required; otherwise keep human threshold 0.agents, humans, or viewers so they receive an access token and can decrypt after execution.waitForWalrus: false and poll pollUrl instead of blocking.title, body, createdAt, and agreementHash must match prepare-create exactly.EXECUTED; reading ciphertext requires a separate Seal integration (see Mysten Seal docs).waitForWalrus: false on publish/sign/mark-executed in production; poll pollUrl every 2–3s.502/504 on manifest writes; mark-executed is safe to retry once the chain tx succeeded.agreementId, pactObjectId, and manifest URL after publish — there is no server database to query later except Walrus.digest for create and execute for auditability.