Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
228 changes: 228 additions & 0 deletions docs/headless-deployment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
# Headless setup

Use this guide if you want to ship `acp-cli` pre-installed on the machines your end users sign in to, with no browser step on first launch. Typical setups: cloud servers, container images, managed devices, anywhere a person isn't going to open a browser to authenticate.

## Becoming a partner

To use the headless flow, you need to be onboarded as a **Virtuals partner**. We'll issue you an API key and walk you through the provisioning endpoint our team exposes for partners.

**Contact us to get started:**

Without partner credentials the CLI still works — just not headlessly. Anyone can run `acp configure` interactively and the browser flow takes care of the rest.

## How it works (in plain terms)

The end user's machine generates its own signing key locally and never reveals the private half to anyone. Your backend then asks the end user to sign a short consent message proving they approved this specific agent. With consent in hand, your backend creates the agent on Virtuals' side and returns CLI tokens that the machine plugs in.

```
[end-user's machine] [end user's wallet] [your backend] [virtuals]
│ │ │ │
│ 1. acp agent generate-signer-key │ │
│ ──────────► generates P-256 keypair locally │ │
│ 2. publicKey ─────────────────► sign EIP-712 consent ──────► │
│ (proves user owns │ │
│ the wallet) │ │
│ │ │
│ 3. call Virtuals with: │
│ { walletAddress, │
│ signerPublicKey, │
│ ownerSignature, │
│ issuedAt } ──────────────────────►│
│ │ │
│ │ ◄─── { agentId, │
│ │ wallet, │
│ │ accessToken, │
│ │ refreshToken } │
│ │ │
│ 4. tokens + agent details ◄─────────────────────────────────│ │
│ 5. acp configure (stores tokens) │
│ 6. acp agent link (records agent) │
```

The private half of the signing key never leaves the user's machine. Your backend doesn't see it. We don't see it. Only the holder of that machine can sign on behalf of the agent.

## Capturing the end user's consent

Before your backend can ask Virtuals to provision the agent, it needs a fresh EIP-712 signature from the end user's EVM wallet. This proves the user actually owns the address you're claiming and approves this specific signer being attached to their wallet.

### Typed-data shape

```ts
const domain = { name: 'Virtuals Partner', version: '1' };

const types = {
PartnerAgentConsent: [
{ name: 'walletAddress', type: 'address' },
{ name: 'signerPublicKey', type: 'string' },
{ name: 'issuedAt', type: 'uint256' },
],
};

const message = {
walletAddress, // end user's EVM wallet
signerPublicKey, // from step 1 above
issuedAt: BigInt(Math.floor(Date.now() / 1000)), // unix seconds; ±10-min window
};
```

### Signing with `viem`

```ts
import { privateKeyToAccount } from 'viem/accounts';

const account = privateKeyToAccount(endUserPrivateKey);
// (or use a connected wallet: MetaMask, WalletConnect, embedded SDK, …)

const ownerSignature = await account.signTypedData({
domain,
types,
primaryType: 'PartnerAgentConsent',
message,
});

// Send `ownerSignature` (0x… hex) and `issuedAt` to Virtuals alongside
// walletAddress + signerPublicKey.
```

### Why each field is in there

- **`walletAddress`** — Virtuals checks the signature recovers to this exact address. Prevents you (or anyone with your API key) from registering agents under EVM addresses the user doesn't control.
- **`signerPublicKey`** — Binds the consent to the specific signer being attached. The same signature can't be reused later to attach a different signer.
- **`issuedAt`** — Virtuals rejects anything outside a ±10-minute window from server time. Stops stale signatures from being replayed.

### What the user sees

Modern wallets show structured EIP-712 fields, so the user's prompt looks like:

```
PartnerAgentConsent
walletAddress: 0xAbC1234567890abcdef1234567890ABCDEF12345
signerPublicKey: MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE…
issuedAt: 1716624000
```

Readable enough that the user can verify the signing key matches what your UI claims, but compact enough not to overwhelm.

## The bootstrap from the machine's perspective

You'll have **two different wallet addresses** flowing through the bootstrap:

- **End user's wallet** — the EVM address (EOA) you set up the user with. Owns the agent. Used to key tokens locally.
- **Agent's wallet** — a separate EVM address Virtuals creates when the agent is provisioned. Used to identify the agent in the local config.

Once your backend has provisioned and returned the agent details, the bootstrap is four CLI calls:

```bash
# 1. Generate the keypair (run once, before contacting your backend).
acp agent generate-signer-key --json
# → { "publicKey": "MFkwEwYHK…" }

# 2. (your backend collects the consent signature and provisions the agent,
# returning the values below)

# 3. Persist the tokens against the end user's wallet.
acp configure \
--token "$ACCESS_TOKEN" \
--refresh-token "$REFRESH_TOKEN" \
--wallet "$END_USER_WALLET_ADDRESS"

# 4. Link the local keypair to the new agent (keyed by the agent's wallet).
acp agent link \
--agent-id "$AGENT_ID" \
--wallet "$AGENT_WALLET" \
--signer-public-key "$PUBLIC_KEY" \
--make-active
```

That's it — no browser opens, no human input required on the machine itself.

### Full bootstrap script

If you're injecting this into a first-boot script, the whole thing fits in one bash file. The script generates the keypair, asks **your** backend to provision the agent (your backend collects the user's signature and forwards the call to Virtuals), and wires the CLI up:

```bash
#!/usr/bin/env bash
set -euo pipefail

# Values you inject from your provisioning system (secrets manager, env, etc.):
# $PARTNER_AGENT_CREATE_URL — your backend endpoint that provisions an agent
# $PARTNER_API_TOKEN — credential the machine uses to call your backend
# $END_USER_WALLET_ADDRESS — the end user's EVM wallet address

# 1. Generate the keypair locally; private half stays on the machine.
PUBLIC_KEY=$(acp agent generate-signer-key --json | jq -r .publicKey)

# 2. Ask your backend to provision the agent. Your backend collects the
# end-user's EIP-712 consent signature (e.g. via a wallet prompt in your
# own UI), then forwards everything to Virtuals.
PROVISION=$(curl --fail -sS -X POST "$PARTNER_AGENT_CREATE_URL" \
-H "Authorization: Bearer $PARTNER_API_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"walletAddress\":\"$END_USER_WALLET_ADDRESS\",\"signerPublicKey\":\"$PUBLIC_KEY\"}")

ACCESS_TOKEN=$(jq -r .accessToken <<<"$PROVISION")
REFRESH_TOKEN=$(jq -r .refreshToken <<<"$PROVISION")
AGENT_ID=$(jq -r .data.id <<<"$PROVISION")
AGENT_WALLET=$(jq -r .data.walletAddress <<<"$PROVISION")

# 3. Persist the tokens against the end user's wallet.
acp configure \
--token "$ACCESS_TOKEN" \
--refresh-token "$REFRESH_TOKEN" \
--wallet "$END_USER_WALLET_ADDRESS"

# 4. Link the local keypair to the agent and make it active.
acp agent link \
--agent-id "$AGENT_ID" \
--wallet "$AGENT_WALLET" \
--signer-public-key "$PUBLIC_KEY" \
--make-active
```

The request/response shape between the machine and your backend is yours to design. The example above is the minimum — your backend is responsible for plumbing the consent signature into the call to Virtuals before relaying the response back. Just be careful with the two wallet addresses: `acp configure --wallet` takes the end user's wallet; `acp agent link --wallet` takes the agent's wallet.

### Env-var alternative

Instead of passing flags, you can set:

```
ACP_ACCESS_TOKEN # access token from your provisioning response
ACP_REFRESH_TOKEN # refresh token from your provisioning response
ACP_OWNER_WALLET # the end user's wallet address (not the agent's)
```

…and run `acp configure` with no flags. Same effect as `--token / --refresh-token / --wallet`.

## Verifying the setup

After the four commands run, the CLI is fully usable. Quick sanity checks:

```bash
acp agent whoami # shows the active agent's name and wallet
acp agent list # makes an authenticated call to Virtuals
```

If anything looks off, the most common cause is a mismatch between the public key the CLI signed with locally and the one your backend told us about — re-run `generate-signer-key` and `link` with the new public key.

## Multiple agents, multiple users

Two different things, two different patterns.

**One end user, several agents.** Use one profile. The access token is user-scoped — it authenticates the user across all of their agents. Run the bootstrap once per agent (each `acp agent link` adds a new entry under the agent's wallet address), then switch between them with:

```bash
acp agent use <agentWalletAddress>
```

No `ACP_CONFIG_DIR` needed.

**Several end users on the same machine.** Each user has different tokens and a different owner wallet, so each one needs their own profile. Use `ACP_CONFIG_DIR` to isolate them:

```bash
ACP_CONFIG_DIR=~/.config/acp/alice acp …
ACP_CONFIG_DIR=~/.config/acp/bob acp …
```

Each profile has its own `config.json`, its own keys in the keychain, and its own active agent.
71 changes: 71 additions & 0 deletions src/commands/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,77 @@ export function registerAgentCommands(program: Command): void {
await runAddSignerFlow(agentApi, json, selected);
});

agent
.command("generate-signer-key")
.description(
"Generate a P-256 signer keypair locally. The private key stays in the keystore; the public key is printed for partner-side agent provisioning.",
)
.action((_opts, cmd) => {
const json = isJson(cmd);
try {
const { publicKey } = generateNativeKeyPair();
if (json) {
outputResult(json, { publicKey });
} else {
console.log(`\nPublic Key: ${publicKey}\n`);
console.log(
"Send this public key to your partner provisioning API. Keep using this CLI on the same machine to retain access to the private key.",
);
}
} catch (err) {
outputError(
json,
`Failed to generate key pair: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
});

agent
.command("link")
.description(
"Link an existing local signer keypair to an agent that was provisioned externally (e.g. by a partner backend).",
)
.requiredOption("--agent-id <id>", "Agent ID returned by the partner")
.requiredOption(
"--wallet <address>",
"Agent's wallet address returned by the partner",
)
.requiredOption(
"--signer-public-key <key>",
"Public key previously emitted by `acp agent generate-signer-key`",
)
.option("--wallet-id <id>", "Privy wallet ID (optional)")
.option(
"--make-active",
"Also set this agent as the currently active one",
false,
)
.action((opts, cmd) => {
const json = isJson(cmd);
const wallet = String(opts.wallet);
try {
setPublicKey(wallet, String(opts.signerPublicKey));
setAgentId(wallet, String(opts.agentId));
if (opts.walletId) setWalletId(wallet, String(opts.walletId));
if (opts.makeActive) setActiveWallet(wallet);
outputResult(json, {
success: true,
agentId: String(opts.agentId),
walletAddress: wallet,
activeWallet: opts.makeActive ? wallet : getActiveWallet(),
});
} catch (err) {
outputError(
json,
`Failed to link agent: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
});

agent
.command("whoami")
.description("Show details of the currently active agent")
Expand Down
Loading