@botanary/agent
v0.1.0-alpha.12
Published
Botanary agent session and execution runtime with an injected signer
Readme
@botanary/agent
Version 0.1.0-alpha.12, canonical API contract 2026-09-12. MIT licensed. Node 22 or later.
The runtime is extracted from botanary-mcp. MCP keeps desktop keychain adapters, profile selection,
owner sessions and approval handles. The CLI consumes that desktop adapter through MCP. The shared
package has no dependency on desktop storage, the Botanary backend or frontend source.
// Node server only. The caller supplies an agent signer whose private key stays in its own storage.
import { AgentRuntime, registerAgent, type AgentSigner } from '@botanary/agent';
import { createBotanary } from '@botanary/sdk';
export async function registerAndRead(signer: AgentSigner) {
const app = createBotanary({ apiKey: process.env.BOTANARY_SECRET_KEY! });
const registration = await registerAgent(signer, app, 'Research agent');
return { id: registration.id, access: registration.apiAccess, grant: registration.grantStatus };
}
export async function readConnectedAgent(signer: AgentSigner) {
const agent = new AgentRuntime(signer);
return agent.me();
}Registration requires app:read and agents:write on the app key. It verifies an EIP-191 challenge
bound to the application, environment, key ID, agent public key, one-use nonce and expiry. It creates
neither an account connection nor an on-chain grant. Pass the returned registration ID to
app.connections.begin({ agentRegistrationId: registration.id, redirectUri, scopes }), including
accounts:read and agents:read in the requested scopes. That app key also needs connections:write
and every requested scope. The owner reviews the public key and explicitly checks the agent API
option for one account. me() then describes that exact binding; registration alone cannot make it
succeed. The owner still signs any spending grant separately.
For app-key rotation, repeat registerAgent with an active same-app/environment key carrying every
existing consent scope. This proves possession again, preserves the customer connection and
invalidates old agent sessions. Revoked owner consent requires a new owner approval. Disabled
registrations cannot be revived through registration proof.
AgentSigner supplies ensure() with public metadata, sign(message) for EIP-191 registration,
and signHash(hash) for raw session and execution digests. createViemAgentSigner(account) adapts
an existing viem-compatible account; it does not load, generate, export or back up a key. Each customer
connection needs a distinct identity. Independent runtime instances have independent cached sessions.
Session creation coalesces concurrent calls, validates nonce and expiry, and refreshes rejected reads once. Mutations are not automatically retried. The default transport uses generated agent routes and openapi-fetch, refuses owner routes and app credentials, forbids redirects and cookies, and bounds response bodies and request time. The injected transport seam is for trusted host adapters such as MCP.
callApi preserves the provider/endpoint requirements, signature and relay protocol. A pending
approval includes its requirementId; resumeApi(requirementId) reads durable payment status before
continuing approval, without making another requirements request. A failed relay carries its original requirement ID. Do not treat an
ambiguous relay failure as permission to create a new purchase. The owner must complete paid-API setup
first; per-agent off-chain mandate checks and the on-chain payment authorization remain distinct.
status: 'submitted' with paymentStatus: 'pending' retains the same requirement while finalized
chain evidence is unavailable. Resume it to read progress; the SDK never signs or forwards an already
submitted payment again. status: 'settled' proves payment, while providerStatus separately records
whether the provider served the request. A provider can fail after payment. Check both fields and the
explicit simulated marker before presenting an outcome.
spendUnderGrant validates the v0.7 UserOp shape, signs once and preserves its hash on relay errors.
awaitTerminal returns the actual last reported state, including a pending state when its budget ends.
Submission is not confirmation. Existing low-level buildDelegatedAction and buildDelegatedSwap
methods retain the legacy display-number wire inputs for CLI/MCP compatibility. New integrations use
prepareExactAction below. Customer binding and real testnet settlement proof remain release work.
No mainnet grant capability is added here.
Exact execution
For the developer-first mandate flow, configure one trusted native gas ceiling when constructing the
runtime, then pass the confirmed mandate projection directly. The facade accepts transfer only and
maps amountRaw without floating-point conversion:
const runtime = new AgentRuntime(agentSigner, { maxGasCostWei: '10000000000000000' });
const prepared = await runtime.prepareAction({
mandate,
action: { type: 'transfer', tokenAddress, recipient, amountRaw: '1000000' },
});
if (prepared.status === 'refused') {
// No transaction was submitted. Render reason and remainingRaw as policy evidence.
return prepared;
}
remember(prepared.userOpHash);
return prepared.submit();The projection must report confirmed creation, active API access, observed active chain authority, the exact account, agent, permission, executor, policy, and remaining limit. The package rechecks the runtime agent identity. Over-limit, wrong-recipient, inactive, expired, and revoked requests return a typed refusal before build or signing. An allowed request continues through the same exact call, nonce-lane, gas-ceiling, hash, signature, one-shot submission, and reconciliation checks described below. OpenAI integration, key loading, persistence, and retries remain host responsibilities.
An already connected agent with an owner-signed grant can prepare an action. Obtain the delegation ID, permission ID and pinned executor address from that approved grant and its network deployment. The app key is never an execution credential. Amounts and the maximum native gas cost are integer strings.
import { AgentRuntime, type AgentSigner, type AgentActionBinding } from '@botanary/agent';
export async function sendUnderApprovedGrant(signer: AgentSigner, binding: AgentActionBinding) {
const runtime = new AgentRuntime(signer);
const prepared = await runtime.prepareExactAction({
accountId: 'your-connected-account-id',
accountAddress: '0x1111111111111111111111111111111111111111',
chainId: 84532,
action: 'transfer',
tokenAddress: '0x036CbD53842c5426634e7929541eC2318f3dCF7e',
recipient: '0x2222222222222222222222222222222222222222',
amount: '1000000',
}, binding);
// Persist this hash with the application's logical operation before submitting.
const hash = prepared.userOpHash;
const receipt = await prepared.submit();
return { hash, receipt };
}Replace the example account and recipient with the owner-approved values. Preparation verifies the account, chain, permission, executor, exact calldata, Smart Sessions nonce lane, native gas ceiling and v0.7 EntryPoint hash. It refuses failed simulations, unexpected factories and paymasters. The on-chain grant remains the authority boundary; advisory simulation is not evidence of settlement.
submit() signs and sends at most once for a prepared handle, including concurrent calls and failures.
It never builds a replacement automatically. Persist the hash before submission. After a timeout use
prepared.reconcile() or, after restarting the client, runtime.getUserOpByHash(chainId, hash).
A 404 means no submission is recorded; it does not prove the network rejected it. Keep the outcome
unknown and reconcile before offering another spend. The prepared handle is process-local; durable
application idempotency across process restarts is a separate platform delivery requirement.
For a swap, use action: 'swap', tokenOutAddress, integer amount, and optional maxSlippageBps.
Pass an AgentSwapBinding that also pins routerAddress and integer minAmountOut. The default
verifier decodes swapExactIn(tokenIn, tokenOut, amountIn, minOut, to) and checks both assets, input,
minimum receive and receiving account. For another reviewed router ABI, supply verifyRoute(facts)
and return true only after decoding and checking every supplied fact. Unknown router calldata is
refused without that verifier. The outer grant call still pins the router, exact allowance and zero
native value. A token address or slippage label in response metadata alone cannot authorize a swap.
API access disablement and on-chain permission revocation are separate. Disabling a registration does
not revoke a previously signed grant. Never interpret grantStatus: unknown as revoked authority.
From this package directory: pnpm install --frozen-lockfile, pnpm check:generated, pnpm typecheck,
pnpm test, pnpm build, pnpm check:inventory, pnpm run audit:artifact, and pnpm test:packed.
Install the independent SDK package before the packed check. The CLI and MCP package integration
checks live in their private repositories.
What ships in the npm tarball
Allowlisted declarations (no declaration maps), one bundled and minified dist/index.js (no source
maps, no comments; viem, openapi-fetch and node: built-ins external), this README, the
changelog and LICENSE. pnpm run audit:artifact verifies the exact tarball.
Every runtime export is classified in
docs/specs/2026-09-10-protected-sdk-integration-boundary.inventory.md.
AgentRuntime carries no planning, tool selection, compilation, or route/provider selection - the
hosted platform re-resolves both swap legs from its own manifest. The only local trust logic is the
exact challenge/digest verifier behind prepareExactAction and registerAgent plus one-shot
signing and submission fencing, each with a written threat argument. No important logic is
distributed; minification is not concealment.
