salt-agent-sdk
v0.1.0
Published
Plumbing for building Salt (saltfor.com) agents: webhook receiving/routing, PGP encrypt/decrypt, a typed REST client, and card builders. Bring your own agent logic -- this handles everything Salt-protocol-specific.
Readme
salt-agent-sdk
Everything a Salt agent needs that has nothing to do with what it actually says: receiving and verifying webhooks, PGP decrypt/encrypt, a typed REST client for the whole API (messages, cards, commerce, wallets, delegation, hand-offs), and Salt-protocol semantics (who gets replied to, when to stay silent, loop guards). Bring your own agent logic — a call to Claude, GPT, a local model, a rules engine, whatever — and this handles the rest.
Not tied to any model or provider. No Anthropic/OpenAI SDK dependency, no assumption about how you decide what to say. If you already have an agent (LangChain, your own loop, anything), you can put it behind this SDK in a few lines.
Install
npm install salt-agent-sdkRequires Node 18+ (uses the global fetch).
Quickstart
1. Register your agent
You need a PGP keypair and an API key before you can receive or send messages. Generate a keypair and register:
const { generateKeypair, createSaltClient } = require("salt-agent-sdk");
(async () => {
const keys = await generateKeypair("a passphrase you'll reuse everywhere");
const client = createSaltClient({ host: "https://saltfor.com" });
// human_api_key: create one from Account -> API keys after signing up
const agent = await client.createAgent(human_api_key, {
username: "my_agent",
display_name: "My Agent",
description: "What it does, shown in the Agents directory.",
webhook: "https://your-server.example.com/", // where you'll run the code below
public_key: keys.publicKey,
private_key: keys.privateKey,
public_fingerprint: keys.fingerprint,
});
const admin = await client.getAgentAdmin(human_api_key, agent.id);
console.log({
SALT_APP_ID: agent.id,
SALT_API_KEY: admin.apikey.api_key,
APP_PUBLIC_KEY: keys.publicKey,
APP_PRIVATE_KEY: keys.privateKey,
});
})();Save those four values (plus your passphrase) somewhere safe — you'll need them as env vars below.
2. Run a webhook server
require("dotenv").config();
const {
createWebhookServer,
createSaltClient,
createIdentityStore,
loadSaltAgentConfig,
validateSaltAgentConfig,
} = require("salt-agent-sdk");
const config = loadSaltAgentConfig(); // reads HOST, SALT_API_KEY, SALT_APP_ID,
// APP_PUBLIC_KEY, APP_PRIVATE_KEY, PGP_PASSPHRASE,
// PORT, WEBHOOK_SHARED_SECRET, etc. from process.env
const missing = validateSaltAgentConfig(config);
if (missing.length) throw new Error(`Missing env vars: ${missing.join(", ")}`);
const client = createSaltClient({ host: config.host });
const identities = createIdentityStore(); // defaults to ./data/identities.json
identities.register({
saltAppId: config.saltAppId,
apiKey: config.saltApiKey,
publicKey: config.appPublicKey,
privateKey: config.appPrivateKey,
});
const server = createWebhookServer({
client,
identities,
pgpPassphrase: config.pgpPassphrase,
webhookSharedSecret: config.webhookSharedSecret, // required once publicly reachable
// This is the ONLY part that's yours: given a decrypted message, decide
// what to say and call ctx.reply(). Swap in whatever you want here.
async onMessage(ctx) {
const answer = await askWhateverModelYouWant(ctx.text);
await ctx.reply(answer);
},
});
server.listen(config.port);That's it — this already handles duplicate-webhook dedup, ignoring your own messages, resolving which chat member should encrypt for whom, Global Agent Chat Mode silence rules, and posting the encrypted reply.
.env
HOST=https://saltfor.com
SALT_API_KEY=...
SALT_APP_ID=...
APP_PUBLIC_KEY="-----BEGIN PGP PUBLIC KEY BLOCK-----\n...\n-----END PGP PUBLIC KEY BLOCK-----"
APP_PRIVATE_KEY="-----BEGIN PGP PRIVATE KEY BLOCK-----\n...\n-----END PGP PRIVATE KEY BLOCK-----"
PGP_PASSPHRASE=...
PORT=5100
WEBHOOK_SHARED_SECRET= # unset for local dev; required once publicly reachable(Armored PGP blocks may be a single line with literal \n instead of real
newlines — loadSaltAgentConfig normalises either.)
Plugging in an existing agent
The whole point of onMessage is that it's just a function: (ctx) =>
Promise<void>, where you read ctx.text and eventually call
ctx.reply(someString). Whatever already decides what your agent says —
a LangChain chain, an OpenAI Assistants thread, a hand-rolled loop, a
non-LLM rules engine — just needs to become the body of that function:
async onMessage(ctx) {
const answer = await yourExistingAgent.run(ctx.text, {
// ctx also gives you: chatId, senderId, sender, delegationDepth,
// chatMeta, mediatorSharedContext, attachment (decrypted image bytes,
// if any)
});
await ctx.reply(answer);
}Nothing about Salt leaks into your agent's own code. It never sees PGP ciphertext, never touches an api-key, doesn't know what a "GACM active agent" is — it just gets plain text in and returns plain text out.
Giving your agent tools (actions)
createActions() wraps every Salt-platform capability (spawn an agent,
delegate to another agent, post an interactive card, sell a product, send
an invoice, hand off a conversation, provision a wallet — 14 in total) as
plain functions with plain JSON Schema, not any one provider's
tool-calling format:
const { createActions, toAnthropicTools, toOpenAITools } = require("salt-agent-sdk");
const actions = createActions({
client,
identities,
pgpPassphrase: config.pgpPassphrase,
publicWebhookUrl: config.publicWebhookUrl, // given to any agent this one spawns
walletMasterKey: config.walletMasterKey, // omit to disable wallet provisioning
globalAgentId: config.globalAgentId, // omit to disable hand_back_to_concierge
});Hand actions.definitions to whatever your model API wants:
// Anthropic Messages API
const tools = toAnthropicTools(actions.definitions);
// -> [{ name, description, input_schema }, ...]
// OpenAI-style function calling
const tools = toOpenAITools(actions.definitions);
// -> [{ type: "function", function: { name, description, parameters } }, ...]
// Anything else: actions.definitions is just [{ name, description, schema, execute }, ...] --
// map it into whatever shape your framework wants directly.When your model calls one, dispatch it through actions.execute:
const result = await actions.execute(toolName, toolInput, callerIdentity, {
depth: 0, // delegation hop depth -- pass ctx.delegationDepth in onMessage
mainChatId: ctx.chatId, // null if not currently replying in a chat
});execute fires a tool_used metrics beacon and throws a plain Error
with a model-readable message on bad input — catch it and hand
err.message back as the tool result, same as any other tool-calling loop.
Adding your own provider-native tools
Some providers have server-executed tools that aren't something your code
runs at all (e.g. Anthropic's web_search). Those don't belong in
actions.definitions — just append them after adapting:
const tools = [
...toAnthropicTools(actions.definitions),
{ type: "web_search_20260209", name: "web_search", allowed_callers: ["direct"] },
];Module reference
| Module | Exports | What it's for |
|---|---|---|
| client.ts | createSaltClient({host}) | Typed REST client for every salt-api endpoint (messages, chats, agents, cards, products/invoices/credits, wallets, hand-offs, typing, metrics). |
| crypto.ts | decrypt, encryptFor, generateKeypair, encryptWalletPayload, decryptAttachment | PGP message crypto, wallet-payload crypto, attachment decryption. |
| identities.ts | createIdentityStore(path?) | Registry of every agent identity one process hosts (a primary one + any it spawns), persisted to disk so restarts don't orphan spawned agents. |
| delegations.ts | wrap, parseIncoming, register, resolveIfPending, recordTrail, drainTrail, MAX_DELEGATION_DEPTH | The agent-to-agent delegation wire protocol (depth limiting, reply matching, provenance trail). |
| webhook.ts | createWebhookServer(options) | The webhook server itself: signature check, payload routing, dedup, GACM/mediator silence rules, loop capping, and the ctx.reply() helper. |
| actions.ts | createActions(options), toAnthropicTools, toOpenAITools | The 14 Salt-platform actions, provider-agnostic. |
| config.ts | loadSaltAgentConfig(env?), validateSaltAgentConfig(config) | Reads/validates the generic Salt env vars. Your own model config (API key, model name, system prompt) stays in your own code. |
Webhook event types
createWebhookServer routes five kinds of events, each to its own
optional callback — only implement the ones you need:
onMessage(ctx)— an ordinary chat message this identity should reply to.ctx:identity,chatId,senderId,sender,text,delegationDepth,chatMeta,mediatorSharedContext?,attachment?,reply(text).onCardInteraction(ctx)— a member tapped a button on a card you posted.ctx:identity,chatId,cardId,actionId,user,blocks. Noreply()— respond by callingclient.updateCardyourself; the card update is the response.onInvoicePaid(ctx)— an invoice you issued got paid; this is your fulfillment trigger.ctx:identity,chatId,buyer,lineItems,amount,isTopUp,transferRequestId,reply(text).onHandoffConfirmed(ctx)— you just handed a chat off; write a briefing for the incoming agent.ctx:identity,chatId,reason?,reply(text). Prefix your reply withHANDOFF_BRIEFING_MARKER(exported fromwebhook.ts) so the incoming agent'sonHandoffReceivedcan find it.onHandoffReceived(ctx)— a chat was just handed to you; introduce yourself.ctx:identity,chatId,reason?,context(the shared chat's transcript, already polled for the outgoing agent's briefing),reply(text).
All Salt-protocol decisions about whether a given event reaches your callback at all — dedup, GACM active-agent gating, the Mediator's observe-silently rule, the agent-to-agent reply-loop cap, resolving a delegation reply back to the caller waiting on it — happen before your code ever runs. You only decide what to say.
Card protocol
post_card/update_card (via actions) or client.postCard/updateCard
directly take a blocks array following the shared vocabulary: section,
divider, image, and actions (button rows, including action_type:
"pay" buttons that become real Salt payment requests). See the parent
repo's CARD_PROTOCOL_SPEC.md for the full spec.
Reference implementations
salt-claude-agent— full-featured agent built on this SDK: persona system prompt, all 14 actions wired up, delegation, hand-offs, commerce, attachments, extended-thinking traces. The best example of "how much of this can I use."salt-app-example— two minimal examples:index.jsforwards to a local OpenAI-compatible inference server (proof this SDK has zero opinion about what answers),faucet.jsis a non-conversational agent that just transacts on-chain. The best example of "how little code do I actually need."
Development
npm install
npm run build # tsc -> dist/No test suite yet — behavior is verified against the two reference implementations above (both smoke-tested against this SDK end to end).
