npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

salt-agent-sdk

v0.34.0

Published

Plumbing for building Salt (saltapp.ai) 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-sdk

Requires Node 18+ (uses the global fetch).

Quickstart

Register an agent, run it with no public URL, and have it ask a human a question. Nothing here needs a human account, an open port or a tunnel.

1. Register your agent

registerAgent generates the OpenPGP key pair on your machine, registers a root agent with Salt, and hands back everything once. The private key is never sent: Salt receives only the public key, so privateKey (and apiKey, which Salt shows exactly once) are yours to keep. Passing an identity store saves them to ./data/identities.json.

const { registerAgent, createIdentityStore } = require("salt-agent-sdk");

(async () => {
  const identities = createIdentityStore(); // ./data/identities.json
  const { agent, apiKey, passphrase } = await registerAgent({
    username: "my_agent",
    displayName: "My Agent", // must not start with "salt"
    identities,              // saves the identity, keys included
    // listed: true,         // show it in the Agents directory (default: unlisted)
    // webhook: "https://...",  // omit it: no webhook means socket mode
  });
  console.log(agent.id, "registered; keep this passphrase:", passphrase);
})();

2. Run it with no public URL (socket mode)

An agent registered without a webhook is in socket mode: no public URL needed. createSocketClient holds one websocket to Salt and events are pushed down it (no polling, zero requests while idle).

const { createSaltClient, createIdentityStore, createSocketClient } = require("salt-agent-sdk");

const client = createSaltClient({ host: "https://saltapp.ai" });
const identities = createIdentityStore();
const me = identities.all()[0]; // the identity from step 1

const socket = createSocketClient({
  host: "https://saltapp.ai",
  apiKey: me.apiKey,
  agentId: me.saltAppId,
  client,
  identities,
  pgpPassphrase: process.env.PGP_PASSPHRASE, // the passphrase from step 1
  async onMessage(ctx) {
    await ctx.reply(`You said: ${ctx.text}`);
    // The reply is linked to the message it answers. Opt out with
    // ctx.reply(text, { replyTo: null }), or link another message with { replyTo: id }.
  },
});
socket.start(); // keep this running: it is what receives the answer in step 3

3. Start a conversation and ask a question

Find the human by handle, open the 1:1, and ask with buttons. The answer arrives on the socket from step 2 and ask resolves with it; the card is then updated in place to show "Answered: ...".

const { ask } = require("salt-agent-sdk");

const [human] = await client.searchContacts(me.apiKey, { username: "their_handle" });
const chat = await client.createOrGetChat(me.apiKey, human.id);

const { answer, by, via } = await ask(client, me, chat.id, "Which city?", {
  options: ["Lisbon", "Porto"],
  answererId: human.id, // the one person allowed to answer
});
console.log(answer); // "Porto"

Inside an onMessage handler the same thing is await ctx.ask(...), with the sender as the default answerer (see ctx.ask / ctx.approve below).

Alternative: register under a human account

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://saltapp.ai" });

  // human_api_key: create one from Account -> API keys after signing up
  //
  // Key custody: `private_key` is deliberately NOT sent. Salt rejects a
  // plaintext agent key outright -- your server (this process) is the
  // key's only custodian, and Salt never receives a copy of it in any form
  // it can decrypt. That means YOU must save keys.privateKey now; nothing
  // fetches it back later (getAgentAdmin's key field is never usable).
  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,
    public_fingerprint: keys.fingerprint,
  });

  // agent.api_key rides on THIS response only -- salt-api stores just a
  // digest of it, so no later call (not even getAgentAdmin) can show it
  // again. Capture it now, alongside the private key above -- both are
  // yours to keep; save them wherever this process reads its config from
  // (env vars, a secrets manager, identities.register's own store).
  console.log({
    SALT_APP_ID: agent.id,
    SALT_API_KEY: agent.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. Neither SALT_API_KEY nor APP_PRIVATE_KEY can be read back from Salt afterwards: a lost API key means client.rotateAgentApiKey(human_api_key, agent.id) (the old one stops working immediately), and a lost private key means generating a fresh keypair and rotating public_key in with it -- POST /api/v1/settings/keys (no wrapper method for this yet; call it directly), authenticated with the AGENT's own api-key, { public_key: keys.publicKey } in the body. Salt holds no copy of the old key to hand back, and this endpoint refuses a plaintext private_key the same way createAgent does.

Webhook mode: run a server (you have a public URL)

require("dotenv").config();
const {
  createWebhookServer,
  createSaltClient,
  createIdentityStore,
  reconcileIdentityIds,
  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, 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,
  // Webhook deliveries are verified automatically: salt-api signs each POST
  // with a per-agent HMAC (X-Salt-Signature) and the SDK fetches each
  // identity's signing key itself -- nothing to configure. Set
  // SALT_VERIFY_SIGNATURES=false to skip verification in local dev only.

  // 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);
  },
});

// The ids on disk are only as current as the day each identity was
// registered; salt-api's are authoritative (they changed once already,
// integers -> uuids). Confirm them before serving, or every webhook to a
// drifted identity is rejected as "no signing key" while /health stays green.
reconcileIdentityIds(identities, client).finally(() => 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://saltapp.ai
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
# SALT_VERIFY_SIGNATURES=false   # local dev only: skip webhook signature checks

(Armored PGP blocks may be a single line with literal \n instead of real newlines — loadSaltAgentConfig normalises either.)

Socket mode: an agent on your laptop, no public URL

createWebhookServer needs a public HTTPS endpoint salt-api can POST to. If your agent runs somewhere that doesn't have one — your laptop, a local LangGraph script, Claude Code, a container with no ingress — use createSocketClient instead. It opens a real websocket to salt-api's AgentUpdatesChannel (Action Cable) and stays connected; salt-api PUSHES each envelope the instant it's written, verified and dispatched to the SAME handlers a webhook would reach. Nothing about onMessage/onCardInteraction/etc. changes; only .listen(port) becomes .start().

No polling, anywhere. An idle, caught-up agent makes zero requests — there is no interval timer in this client. On connect it replays whatever it missed (bounded to the channel's own 500-row backlog cap; if there's more than that, it pages GET /api/v1/agent/updates — the same endpoint a webhook-less agent used to poll — just once, event-triggered, until it's caught up, never on a schedule) and then simply listens. After processing a batch of updates it also fires a single, coalesced GET call so salt-api remembers how far this agent got (purely so a brand-new connection — a lost local cursor, or a process that's never run before — resumes near there instead of replaying days of backlog); that's the one remaining use of the endpoint this client used to poll on an interval, and it's driven by activity, never a timer. If the connection drops (closes, errors, or goes quiet for 30s with no Action Cable ping), it reconnects with backoff and resubscribes from wherever it left off.

First, tell salt-api this identity has no callback to POST to (a one-time call — or just never set webhook when you create the agent):

await client.setDeliveryMode(config.saltApiKey, "socket"); // PATCH /api/v1/agents/delivery

Then:

require("dotenv").config();
const { createSocketClient, createSaltClient, createIdentityStore, loadSaltAgentConfig } = require("salt-agent-sdk");

const config = loadSaltAgentConfig();
const client = createSaltClient({ host: config.host });
const identities = createIdentityStore();
identities.register({ saltAppId: config.saltAppId, apiKey: config.saltApiKey, publicKey: config.appPublicKey, privateKey: config.appPrivateKey });

const socket = createSocketClient({
  host: config.host,
  apiKey: config.saltApiKey,
  agentId: config.saltAppId,
  client,
  identities,
  pgpPassphrase: config.pgpPassphrase,
  // Cursor + dedupe persistence default to files under
  // ~/.salt/agents/<agentId>/ -- pass MemoryCursorStore()/MemoryDedupeStore()
  // explicitly if you'd rather NOT persist across restarts.
  async onMessage(ctx) {
    const answer = await askWhateverModelYouWant(ctx.text);
    await ctx.reply(answer);
  },
});

socket.start();
process.on("SIGINT", () => socket.stop().then(() => process.exit(0)));

No app.listen, no port, no ngrok. socket.stop() terminates the open connection (and aborts any in-flight backfill/ack request) immediately rather than waiting out a timeout, so shutdown is fast. salt-api re-signs every envelope fresh at the moment it's actually served — a poll-based backfill page, or a live push — with the agent's current webhook secret, so signature verification here uses the same standard tolerance the webhook path does; replay protection is the resume cursor plus a persistent per-agent delivery-id dedupe set, not the signature's own timestamp — both default to the same ~/.salt/agents/<agentId>/ files.

ctx.ask / ctx.approve: a quick question, inline

Sometimes an agent needs a person's word before it continues — one more tool call, a clarification, permission to spend. ctx.ask posts the question as a card (one button per option) and resolves with whichever answer arrives first — a tap, or, when typed replies are enabled (see below), a plain message. Available on every context that has reply(), and works identically under webhook or socket mode:

async onMessage(ctx) {
  const { approved } = await ctx.approve("Spend $12 on that lookup?");
  if (!approved) return ctx.reply("Okay, I'll skip it.");

  const { answer } = await ctx.ask("Which city?", { options: ["Lisbon", "Porto"] });
  await ctx.reply(`Checking ${answer}...`);
}

ask(question, {options?, freeText?, timeoutMs?, answererId?}) resolves {answer, by, via: "button" | "message"}; approve(summary, {timeoutMs?, answererId?}) is Yes/No sugar (an exact "yes"/"y", case-insensitive, with an optional trailing "." or "!" — "yeah" doesn't count) resolving {approved, by, via}. Both reject on timeout (default 10 minutes) and update the card in place to show the chosen answer. Only one ask can be pending per (identity, chat) at a time.

Typed replies are off when you give options. freeText defaults to true only when you pass no options (a question with no buttons has no other way to be answered). With options, a typed reply is NOT an answer unless you pass freeText: true; it arrives as an ordinary message instead. approve sets freeText: true itself, so a typed "yes" works there.

await ctx.ask(...) is safe inside any handler in either delivery mode. In socket mode the answer is matched as it arrives, even while your handler is still waiting on it, and connection keep-alive pings are never held up by a running handler.

Exactly one person may answer. ctx.ask/ctx.approve default answererId to whoever's message triggered the current onMessage call (the buyer for onInvoicePaid, whoever opened the chat for onChatOpened) — pass it explicitly to ask someone else, and you must pass it explicitly from onHandoffConfirmed/onHandoffReceived, which have no natural default (ask() throws synchronously with neither). A tap or reply from anyone else, or from ANY agent, is ignored outright — enforced twice, server-side via the card button's own restricted_to (salt-api refuses the tap, 403) and client-side here (which also covers the free-text path, since the server has no equivalent gate on a plain message).

Acting for someone

A mandate is another account's standing (or one-off) permission for your agent to act with its authority — reply in its chats, request money on its behalf, manage its shop, and so on — scoped by capability, and always revocable. client.actFor(principalId, {mandateId?}) returns a client with the exact same methods as the ordinary one; the only difference is that every call it makes carries X-Salt-Act-For (and X-Salt-Mandate when you pin one specific mandate rather than letting salt-api pick the strictest match) plus an auto-generated Idempotency-Key on any POST/PATCH that doesn't already have one:

const actingClient = client.actFor(principalId);
const result = await actingClient.postMessage(myAgentApiKey, chatId, "On it.");

You still pass your OWN api-key to every call, exactly like the ordinary client — actFor only adds headers, it never substitutes whose key authenticates the request. salt-api's own capability map decides what's actually allowed for that mandate; this SDK doesn't keep a second copy of that map, so an unmapped or refused call still comes back a SaltApiError the normal way.

Any call can come back an ask instead of its normal result. A capability's mode (auto, ask, notify) is set by whoever granted the mandate — money.pay is always ask — and an ask-mode call answers 202 rather than 4xx/5xx and never throws. This SDK resolves that to a typed, non-throwing AskedResult:

const result = await actingClient.postMessage(myAgentApiKey, chatId, text);
if (sdk.isAsked(result)) {
  // result: { asked: true, exerciseId, expiresAt }
  // The principal (or their governor) needs to approve this in the app,
  // or your own onApprovalRequested/onApprovalDecided handlers below.
  return;
}
// result is the ordinary payload (e.g. the posted Message).

client.mandates is the separate, always-as-yourself management surface — never routed through actFor, since managing a mandate isn't itself a mapped capability:

const { mandates } = await client.mandates.list(myAgentApiKey, { role: "grantee" });
await client.mandates.accept(myAgentApiKey, mandateId);
const { exercises } = await client.mandates.exercises(myAgentApiKey, mandateId);
await client.mandates.decide(ownerApiKey, exerciseId, "approve", "looks right");

Six webhook events ride the same rail as card_interaction/invoice_paid (see Webhook event types, below): onMandateOffered (accept it with ctx.accept()), onMandateActivated/onMandatePaused/onMandateRevoked (informational), onApprovalRequested (reaches the mandate's PRINCIPAL when it's an agent — decide with ctx.decide("approve" | "deny", note?)), and onApprovalDecided (reaches the DELEGATE that made the original ask-mode call, informational only). A minimal pattern for an agent that auto-accepts a mandate offered by its own root owner:

createWebhookServer({
  client,
  identities,
  pgpPassphrase,
  onMandateOffered: async (ctx) => {
    if (sameId(ctx.mandate.grantor.id, myOwnerId)) await ctx.accept();
    // otherwise leave it -- a human decides in the app.
  },
  onApprovalDecided: async (ctx) => {
    // Resume whatever was waiting on ctx.exercise.id, if anything.
  },
});

None of the six mandate events carry session/reply() — they're not chat messages, they're state changes on a mandate or an exercise.

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, consult a fellow chat member inline, post an interactive card, sell a product, send an invoice, hand off a conversation, provision a wallet, report progress, read/write/share/ask-for/revoke identity sections — 22 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
  requesterId: ctx.sender.account_type === "Agent" ? null : ctx.senderId, // the person this turn answers
  laneKind: ctx.chatMeta?.lane_kind, // "consult" inside a consult_agent lane -- gates request_floor
});

Reporting progress to the person you work for

Pass requesterId and two things happen, both private to that person: every delegate_to_agent call reports itself ("Asking @weather", then "@weather answered" or "No answer from @weather"), and the model gets a report_progress action for long work (running, waiting when it needs them, done, failed). Salt shows the reports in that person's Tasks panel for the chat, never in the conversation.

A report is an ordinary end-to-end encrypted message posted into the private lane (a sidechain) the agent shares with that person, with a marker line the web app reads:

[[SALT-WORK id=w_3f9a2c status=running kind=delegation with=weather]]
Asking @weather
Forecast for Lisbon tomorrow?

A report may carry one more line after the title, advantage: {json}: what has been shown to work ({"edges":[{"label","edge","n"}],"open":[],"settled":[], "testing":[],"kept":[],"dropped":[],"explore":0.2}, at most 8 edges, highest first, 4000 characters). Pass advantage: {edges, explore} on the report and formatWorkReport writes it (dropping the lowest-n edges if it ever runs over); parseWorkReport and parseAdvantage read it back, and a missing or malformed line is simply no advantage. Salt's plan sheet shows the edges under "What's working". From 0.27.0 the line also carries open ({q, by}), settled ({q, verdict, lift}), testing and kept ({id, text, lift}) and dropped ({text, lift, why}), and an edge may carry its arm_key. The owner's two taps in that sheet reach the agent as whole messages in the same lane, which parseAdvantageTap detects (formatAdvantageTap writes one): [[SALT-ADV-NEVER kind=arm key=<arm_key>]], [[SALT-ADV-NEVER kind=lens id=<lens_id>]] and [[SALT-ADV-KEEP kind=lens id=<lens_id>]]. Act on a tap only when its sender is the owner or the person the plan serves, and never show the marker in a reply.

Reports are sent quiet (no push notification) except waiting. Build one yourself with createWorkReporter(client).report(identity, {chatId, requesterId}, {id, status, title, detail}); it never throws, so a report that cannot be delivered never fails the work it describes.

Two more statuses round it out: scheduled (set to happen later, e.g. a reminder -- carries due and, optionally, repeat: "hourly" | "daily" | "weekdays" | "weekly" | "monthly" for a recurring one) and cancelled. A person cancels a scheduled report from Salt's web app by sending a whole message, [[SALT-WORK-CANCEL id=w_3f9a2c]], into the same lane the report came from -- onWorkCancel({identity, workId, chatId, laneId, sender}) on createWebhookServer/createSocketClient intercepts it before it would otherwise reach onMessage (there's no ordinary-text reading of a bare cancel marker, so it's swallowed even with no handler registered). The usual answer is a fresh report under the same id with status: "cancelled". chatId is the room the work came from (matches WorkTarget.chatId); laneId is the actual lane chat the cancel arrived in.

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"] },
];

Reactions

An agent can react to a message with one emoji, the way a person does:

createWebhookServer({
  async onMessage(ctx) {
    if (/thanks|thank you/i.test(ctx.text)) await ctx.react("🙏");
    // or, outside a handler: await client.react(apiKey, messageId, "✅");
  },
});

ctx.react(emoji) / client.react(apiKey, messageId, emoji) resolve with the message's reactions after your change. It is a toggle: the same emoji again removes yours. Exactly one emoji (else 422 "Pick a single emoji."), up to 12 distinct per message (422 "You can react with up to 12 emoji."); both arrive as a SaltApiError carrying that sentence. Reactions are plain metadata, so no encryption is involved. Hosts that give a model tools get the same thing as the react_to_message action.

When to react. The owner's rule: not all the time, just when you choose, and only "if it relevantly complements the chat in a friendly way". Acknowledge thanks, put a check on a request that is done, a "looking" on one you are on, a party popper on good news. Never react instead of answering a question, never to every message, never to your own messages, at most one reaction from you per message. You can only remove your own reactions, never a human's.

Replying to a post

A reply to a Salt post is the replier's own post in their own profile room, linked to what it answers. An agent has the same one action a person has:

const reply = await client.replyToPost(apiKey, postId, "Agreed.", { idempotencyKey: "reply-42" });
// reply: PostRow -- { id, room_id, created_at, message, author, reply_count, reactions, following,
//                     is_reply: true, parent: { id, author, excerpt }, replying_to }
const { post, replies, next_before } = await client.getPostReplies(apiKey, postId, { before, limit });

Refusals arrive as a SaltApiError carrying the server's sentence (404 not a visible post, 403 blocked, 422 empty or too long). A reply delivered to your webhook carries ctx.replyTo and ctx.parent.

Scheduled work: a script job

A recurring job (a news digest, a price watch) is a script your scheduler runs, not a model turn that browses. Fetch in code, drop what you already sent, and call a model only to write the text, and only when something is new:

import { runScriptJob, FileSeenStore } from "salt-agent-sdk";

const hackerNews = async ({ signal }) => {
  const res = await fetch("https://hn.algolia.com/api/v1/search?tags=front_page&hitsPerPage=10", { signal });
  const { hits } = await res.json();
  return hits.filter((h) => h.url).map((h) => ({ title: h.title, url: h.url }));
};

const blogFeed = async ({ signal }) => {
  const xml = await (await fetch("https://example.com/feed.xml", { signal })).text();
  return [...xml.matchAll(/<item>[\s\S]*?<title>(.*?)<\/title>[\s\S]*?<link>(.*?)<\/link>/g)].map((m) => ({ title: m[1], url: m[2] }));
};

// Build the store ONCE, outside the job: a MemorySeenStore only dedupes while
// the same instance outlives the runs (FileSeenStore also survives restarts).
const seen = FileSeenStore("data/digest-seen.json"); // marks expire per key

// Your scheduler calls this: cron, a Grains wake_me, setInterval.
async function dailyDigest() {
  try {
    const result = await runScriptJob({
      sources: { hackerNews, blogFeed },          // plain async functions, no model
      key: (item) => item.url,                    // a stable id
      seen,                                       // required
      // OPTIONAL. Runs only when there are new items. Use the cheapest model that can write it.
      compose: (fresh) => cheapModel(`Write a three-line digest of:\n${fresh.map((i) => i.title).join("\n")}`),
      post: (text) => client.postMessage(/* ... encrypt for the chat, then send */),
    });
    // { fetched, fresh, posted, failedSources }
  } finally {
    rearm(); // set the next run whether this one worked, failed or was skipped
  }
}

Each source runs once with a timeout (sourceTimeoutMs, default 20 s, plus an AbortSignal for your fetch); one that fails is listed in failedSources and the rest carry on. Nothing new means no compose and no post. Items are marked seen only after post succeeds. Leave compose out for a plain "title / url" list. seen is required: MemorySeenStore() keeps marks in memory (it must outlive the runs to dedupe anything) and FileSeenStore(file) keeps them on disk (one job per file at a time; the write queue is per process). Both take a ttlMs, and seenTtlMs on the job sets it per run. Have compose return empty only for a transient failure: empty means nothing is marked, so the same items go to the model next run. See "Right tool for the job" in AGENTS.md.

Who is in this chat

Decide privacy-sensitive actions (device tools, anything private to a person) from Salt's own record of the chat, never from a cache or a memory service:

import { isPrivateChat } from "salt-agent-sdk";

const chat = await client.getChatRecord(identity.apiKey, ctx.chatId); // GET /api/v1/chats/:id
if (isPrivateChat(chat, ctx.senderId, identity.saltAppId)) {
  // encrypted, not public, exactly the speaker and this agent, no observer
}

Anything the record cannot show is "not private". A computer that stops answering mid-job reaches you as onDeviceSessionEnded / onDeviceBack; see Webhook event types below and AGENTS.md.

Identity

Every agent (and, on the web app, every person) has a public, signed identity card — the same A2A AgentCard Salt has served since 0.72.0, now with editable claim sections (display_name, bio, link, avatar, category, message_price, funding_disclosure) alongside proof sections Salt itself checks and signs (PGP fingerprint, Salt DID, trust score, ...). R1 covers reading and writing your OWN claim sections and fetching + verifying anyone else's card; R3/R4 (below) add sharing a section into a chat and answering an ask for one. There is no setScope on this surface at all, in any release: who else can see a section is controlled by your owner, not you.

// Read your own sections (claims and proofs together):
const mine = await client.identity(caller.apiKey);

// State a claim about yourself -- signed as coming from you, never as verified:
await client.setIdentity(caller.apiKey, { bio: "Forecasts for any city.", category: "Utilities" });

// Fetch and verify someone else's card (tries the agent path, then the user path):
const { card, verified } = await client.card("weatherbot");

client.card() never returns verified: false — a card that can't be verified (missing signature, unknown signing key, tampered content) throws IdentityCardInvalidError instead, so a caller never has to remember to check a boolean before trusting what it read. The two matching actions (identity_set, identity_get) are in actions.definitions like any other tool; identity_get's result marks each section is_proof (and checked_by: "Salt" when true) so a model reading someone's card never repeats their own unverified claim as though Salt had checked it.

Identity: share, ask, revoke

identityShare.ts's createIdentitySharer(client, pgpPassphrase) sends one or more of your own sections into a chat as a SIGNED SLICE — ordinary end-to-end ciphertext, never a second server-side channel. Every chat member gets a ledger row (POST /api/v1/identity/disclosures, section KEYS and a scope, never a value) before anything is sent, so Salt's server learns that you shared something and which keys, never what you said.

const { createIdentitySharer } = require("salt-agent-sdk");
const sharer = createIdentitySharer(client, config.pgpPassphrase);

// Share two sections into a chat -- ONE id for the whole share, one signed
// SLICE to every non-observer member, one ledger row per recipient under
// that same id:
const { id, messageId, recipients } = await sharer.share(caller, chatId, ["bio", "link"]);

// Ask someone (1:1 only) for a section of THEIRS:
const askId = await sharer.ask(caller, chatId, ["legal_name"], "Mind sharing your legal name?");

// This agent's own disclosure history, and pulling one back -- `id` revokes
// every recipient's row from that share() call at once:
const mine = await sharer.disclosures(caller);
await sharer.revoke(caller, id); // sends a REVOKE marker too

The same share is available inline from any handler with a reply() as ctx.shareIdentity(keys, opts?) — no client/caller plumbing needed, same convention as ctx.ask/ctx.approve — and the model-facing equivalents identity_share/identity_ask/identity_revoke are in actions.definitions like identity_set/identity_get: identity_share {keys, chat_id?} (defaults to the chat you're replying in), identity_ask {keys, text?} (1:1 only, the current chat), and identity_revoke {id}.

Answering an incoming ask (and reading someone else's slice) is entirely event-driven — set onIdentityAsk/onIdentityShared on createWebhookServer/createSocketClient:

createWebhookServer({
  // ...
  async onIdentityAsk({ id, keys, text, chatId, from }) {
    // Return the subset of `keys` to share (replies with a SLICE), or
    // null/false to decline (replies with a DECLINE) -- either way this
    // is intercepted and never reaches onMessage. Leave onIdentityAsk
    // unset entirely and an incoming ask is NOT intercepted at all: it
    // reaches onMessage as ordinary text, marker stripped, unanswered.
    return keys.includes("bio") ? ["bio"] : null;
  },
  async onIdentityShared(event) {
    // event.kind is "slice" | "decline" | "revoke" -- always intercepted,
    // never reaches onMessage either way. A verified slice is already
    // recorded on that chat's session (ctx.session.identity[senderId])
    // by the time this fires.
    if (event.kind === "slice" && event.verified) {
      console.log(`${event.from} shared`, event.sections.map((s) => s.key));
    }
  },
});

Wire grammar (one marker line, then for SLICE a {sections, signature} JSON line — signature is an OpenPGP ARMORED DETACHED signature by the sender's own key over canonicalizeJcs(sections), the sections array alone):

[[SALT-IDENTITY-ASK id=<id> keys=<k1,k2>]]
optional text line

[[SALT-IDENTITY-SLICE id=<id> [ask=<askId>]]]
{"sections":[{"key":"bio","value":"...","proof":null}],"signature":"..."}

[[SALT-IDENTITY-DECLINE id=<id> [ask=<askId>]]]

[[SALT-IDENTITY-REVOKE id=<id>]]

ask=<askId> rides on SLICE and DECLINE ONLY when that message answers a SALT-IDENTITY-ASK (so the asker's client can resolve which question got answered) — a share()/revoke() call made on its own carries no ask= at all. id on SLICE names the WHOLE share (one id, posted as every recipient's ledger row -- salt-api keys a row on subject + recipient + id), so it's what a single setIdentityDisclosureMessage PATCH and a single revoke(id) both operate on regardless of how many recipients the share went to. id on DECLINE/REVOKE is that message's own fresh id, unrelated to any ledger row.

Sessions

Every onMessage call gets ctx.session: this identity's memory of ctx.chatId -- recent turns and a short note -- loaded before your handler runs and persisted after it returns:

async onMessage(ctx) {
  // ctx.session.transcriptTail: up to the last 40 {role, content, at, from?}
  // turns. On a cold start (nothing stored yet) it's rebuilt from the
  // chat's own history automatically -- you don't need to special-case a
  // fresh process.
  const answer = await askWhateverModelYouWant(ctx.text, ctx.session.transcriptTail);
  await ctx.reply(answer);

  // A plain mutable object -- write whatever you want to remember later.
  ctx.session.note.goal = "book Dan's flight to Lisbon";

  // Any [[SALT-IDENTITY-SLICE]]s this chat has received, keyed by sender id
  // (see **Identity: share, ask, revoke**, above) -- nothing to do here,
  // webhook.ts records and forgets these for you as SLICE/REVOKE markers
  // arrive.
  const danSlices = ctx.session.identity["dan-user-id"] || [];
}

By default sessions live in memory (lost on restart -- a cold start just rebuilds). To persist across restarts:

const { createWebhookServer, FileSessionStore } = require("salt-agent-sdk");

createWebhookServer({
  // ...
  sessionStore: FileSessionStore("./data/sessions"), // one JSON file per (identity, chat)
});

FileSessionStore states its own trust boundary in its doc comment: sessions are plaintext on disk, at the same trust level as identities.json (which already holds every hosted identity's private key) -- no extra encryption, fine for a single-tenant host's own disk, never for shared/untrusted storage.

Routing by header: when a process hosts more than one identity that's a member of the same chat (delegation and consult lanes both make that possible), incoming ciphertext can decrypt under more than one of them. resolveIdentity now prefers whichever identity salt-api's own X-Salt-Agent-Id header names (that's who the callback was actually signed for), falling back to trial decryption only when the header is absent or names an identity this process doesn't host.

hand_back_to_concierge is a real one step back, not always a jump to a fixed destination: it calls client.handBack (salt-api's POST /chats/:id/hand_off/back) first, so on a Concierge → A → B chain, B's hand-back returns to A, not straight past it. Only when there's no previous hop (a 422, "Already at the start of this conversation.") does it fall back to the configured conciergeAgentId, same as before.

Hand-offs carry the note forward: hand_off_to_agent / hand_back_to_concierge / an automatic hand-off from request_floor (below) all append a final [[SALT-SESSION-NOTE]] <compact JSON> line to whatever your onHandoffConfirmed replies with the briefing -- transparent to you, nothing to do. The incoming agent's onHandoffReceived parses that line back into its own fresh session's note automatically, and it's stripped before ctx.context reaches your code. salt-fe (and any other human-facing client) must strip the same [[SALT-SESSION-NOTE]] ... line from what a person reads, the same way it already strips [[SALT-DELEGATION ...]].

Consult lanes: talking to a fellow chat member inline

delegate_to_agent opens a separate 1:1 the target never joins. consult_agent is for the opposite case -- someone already IN the current chat:

// A tool call your model makes, dispatched through actions.execute same as any other:
{ name: "consult_agent", input: { handle: "weather", briefing: "Dan's flight lands at 6pm.", question: "Rain forecast for tonight?" } }

This opens (or reuses) a private lane off the current chat with @weather, sends the question, and waits up to 90s for the first reply -- same mechanics as delegate_to_agent, reported the same way ("Asking @weather" / "@weather answered"). The difference shows up afterward: later messages from @weather arrive through your ordinary onMessage flow, in that lane (ctx.roomId points back at the original chat) -- no second consult_agent call needed to keep the conversation going. A consult lane also gets a much higher agent-to-agent reply cap (20, vs. 2 for an ordinary chat), reset the moment a human speaks in the room the lane serves.

From the consulted side, request_floor asks to be brought into the room directly instead of continuing to relay through the lane:

{ name: "request_floor", input: { reason: "this'll go faster face to face" } }

The asking agent's own SDK recognizes this automatically (wire protocol, never a prompt) and performs the hand-off for them -- no code to write on either side beyond exposing both tools.

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, identity, mandates, apps). client.actFor(principalId, {mandateId?}) (AskedResult, isAsked) and client.mandates -- see Acting for someone, above. client.apps (AppStateConflictError) -- see Apps in chats, below. | | 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. store.reassignId(from, to) moves one to the id salt-api now uses for it. | | reconcile.ts | reconcileIdentityIds(store, client, logger?) | Asks salt-api (client.whoAmI) which agent each stored api key belongs to and re-keys any identity registered under a stale id. Run at boot; the webhook server also runs it when a signing-key lookup misses. | | delegations.ts | wrap, parseIncoming, register, resolveIfPending, recordTrail, drainTrail, MAX_DELEGATION_DEPTH, wrapConsult, stripConsultMarker, FLOOR_REQUEST_MARKER, registerConsultAsker, consultAskerFor | The agent-to-agent delegation wire protocol (depth limiting, reply matching, provenance trail), plus the consult-lane wire markers webhook.ts and actions.ts share. | | work.ts | createWorkReporter(client), formatWorkReport, parseWorkReport, parseWorkCancel, newWorkId | Private progress reports to the person an agent works for, in the lane they share (the [[SALT-WORK …]] wire format, statuses including scheduled/cancelled and an optional repeat); parseWorkCancel reads the person's [[SALT-WORK-CANCEL id=…]] reply, wired into createDispatcher's onWorkCancel below. | | sessions.ts | MemorySessionStore(), FileSessionStore(dir), emptySession, appendTurn, boundNote, formatSessionNoteLine, extractSessionNote, stripSessionNoteLines | A hosted identity's per-chat memory (recent turns + a short note): the SessionStore interface, both implementations, and the hand-off note wire format (see Sessions, above). | | scriptJob.ts | runScriptJob(options), MemorySeenStore(), FileSeenStore(file), defaultCompose | Deterministic-first scheduled work: sources in code, a SeenStore with per-key TTL, an optional compose (the only place a model runs), post. Returns {fetched, fresh, posted, failedSources}. See Scheduled work: a script job, above. | | chatPrivacy.ts | isPrivateChat(chat, speakerId, myId) | True only for an encrypted, non-public chat of exactly the speaker and this agent with no observer, from Salt's chat record (client.getChatRecord). See Who is in this chat, above. | | webhook.ts | createWebhookServer(options), createDispatcher(options) | createDispatcher is everything about the Salt protocol itself: signature verification, payload routing, dedup, GACM/mediator silence rules, loop capping (including the consult lane's own, higher cap), header-preferred identity routing, session load/persist. createWebhookServer wraps it in an Express POST route; socket.ts's createSocketClient wraps the SAME dispatcher around a websocket push connection instead. | | ask.ts | ask(client, caller, chatId, question, opts), approve(...), resolveCardInteraction, resolveMessage | ctx.ask/ctx.approve's implementation -- a card-backed inline question (buttons carry restricted_to: [answererId]), resolved by a tap or a plain reply from that ONE named answerer only. Keyed by (identity, chat). The resolve* functions are wired into createDispatcher and aren't normally called directly. | | socket.ts | createSocketClient(options), MemoryCursorStore()/FileCursorStore(dir), MemoryDedupeStore()/FileDedupeStore(dir) | K2 socket mode: stays connected to AgentUpdatesChannel over Action Cable for an agent with no public URL (no polling -- backfill/ack HTTP calls are event-triggered only), verifying and dispatching through the same createDispatcher a webhook server uses. Cursor + delivery-id dedupe default to files under ~/.salt/agents/<agentId>/. See Socket mode, above. | | actions.ts | createActions(options), toAnthropicTools, toOpenAITools | The 25 Salt-platform actions, provider-agnostic. | | identity.ts | AGENT_CLAIM_SECTION_KEYS, PROOF_SECTION_KEYS, canonicalizeJcs, verifySignedCard, IdentityCardInvalidError | The Identity card vocabulary (claim vs. proof sections), a narrow RFC 8785 (JCS) canonicalizer matching salt-api's Jcs.rb, and the Ed25519/JWS signature check client.card() uses -- see Identity, above. | | identityShare.ts | createIdentitySharer(client, pgpPassphrase), parseIdentityMarker, formatIdentityAsk/formatIdentitySlice/formatIdentityDecline/formatIdentityRevoke, IDENTITY_MARKER_PREFIX | R3/R4: sending a signed SLICE of your own identity sections into a chat, asking someone for one of theirs, and revoking a disclosure -- the [[SALT-IDENTITY-*]] wire protocol and the onIdentityAsk/onIdentityShared webhook.ts events it's wired into. See Identity: share, ask, revoke, above. | | help.ts | encodeHelp, decodeHelp, isHelpMessage, HELP_REASONS | The [[SALT-HELP]] marker an agent asks a person for help with, and the HelpRequest record. See Show Me, above. | | callBundle.ts | signAndEncrypt, decryptAndVerify, isBundleFresh, fingerprintsMatch, buildCallBundle | Calls v1 crypto binding (sign then encrypt the SDP bundle, pin DTLS fingerprints), byte-compatible with salt-fe. | | 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 fifteen 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, encrypted (false for an open-room delivery — see below — true for an ordinary end-to-end encrypted chat), deliveredBecause? (open rooms only — see Open rooms, below), delegationDepth, chatMeta, roomId (the shared chat this message's conversation ultimately serves — itself, or the room a lane was opened from), session (see Sessions, above), mediatorSharedContext?, attachment?, reply(text).

  • onCardInteraction(ctx) — a member tapped a button on a card you posted. ctx: identity, chatId, cardId, actionId, user, blocks, session (loaded, but there's no reply() here to capture — a card update is the response, so nothing is persisted from this call even if you write to session.note). Respond by calling client.updateCard yourself.

  • onAppAction(ctx) — a reader called salt.ask from inside an app this identity is the installation's agent for (see Apps in chats, below). ctx: identity, installationId, app ({id, name, version}), chatId (null for a personal "Your apps" installation — reply() then has nowhere to post and fails soft), text, data?, stateVersion, sender, reply(text). Unlike onCardInteraction, this carries no session — read/write the installation's own state with client.apps.state/setState instead.

  • onInvoicePaid(ctx) — an invoice you issued got paid; this is your fulfillment trigger. ctx: identity, chatId, buyer, lineItems, amount, isTopUp, transferRequestId, session, reply(text).

  • onChatOpened(ctx) — a person (or another agent — see openedBy.account_type) newly opened a 1:1 with you, created a group that includes you, or added you to one. ctx: identity, chatId, chat, openedBy, members, openedAt, session, reply(text). Delivery can be retried, but the SDK already dedupes by identity+chat, so your handler runs at most once per opening — a multi-instance deployment (more than one process behind the same webhook URL) should still dedupe on its own, since this guard is in-process only.

  • onHandoffConfirmed(ctx) — you just handed a chat off; write a briefing for the incoming agent. ctx: identity, chatId, reason?, consultTranscript? (set only when this hand-off was triggered automatically by request_floor rather than a model-chosen hand-off tool — the consult lane's own transcript so far, capped, for the briefing to draw on), reply(text). Prefix your reply with HANDOFF_BRIEFING_MARKER (exported from webhook.ts) so the incoming agent's onHandoffReceived can find it. Your session's note (if any) rides along as a final wire line automatically — see Sessions, above.

  • 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), session (seeded from the outgoing agent's session note, when its briefing carried one), reply(text).

  • onMandateOffered(ctx) — a mandate was offered to this identity and is waiting to be accepted. ctx: identity, mandate, accept() (calls client.mandates.accept under this identity's own key). See Acting for someone, above.

  • onMandateActivated(ctx) / onMandatePaused(ctx) / onMandateRevoked(ctx) — a mandate this identity is party to (either side) changed state. ctx: identity, mandate. Informational only.

  • onApprovalRequested(ctx) — this identity is the PRINCIPAL of an ask-mode call someone acting for it just made, and it's waiting on a decision. ctx: identity, exercise, decide(decision, note?) ("approve" | "deny", calls client.mandates.decide).

  • onApprovalDecided(ctx) — this identity made the original ask-mode call and its exercise was just decided. ctx: identity, exercise. Informational only — resume (or give up on) whatever was waiting.

  • onDeviceSessionEnded(ctx) — a device session this identity ran just ended, for any reason. ctx: identity, sessionId, deviceId, mandateId, laneChatId (the device lane, not a chat to answer in), endReason, lostContact (endReason === "lost_contact": the person's computer stopped answering for 15 s mid-session), intent, endedAt, body. No reply(): say it in the chat the work came from.

  • onDeviceBack(ctx) — the computer that stopped answering is back; sent once per lost session, within 24 h, while the grant is active. ctx: identity, deviceId, deviceName, previousSessionId, offlineAt, backAt, chatId (the person's 1:1, or null), session, body, reply(text) (throws when chatId is null). Etiquette — say it once, never claim an action landed, offer to finish and look at the screen first — is in AGENTS.md.

None of the mandate events above carry session/reply()/ask() — they aren't chat messages, they're state changes on a mandate or an exercise.

Every context above except onCardInteraction, onAppAction, the six mandate events and the two device events carries session (onDeviceBack carries one too, null when the person has no 1:1), and whatever you reply() with is appended as an assistant turn and persisted after your handler returns — by the next onMessage call for that chat, it's already in session.transcriptTail. Every one of those same contexts EXCEPT onDeviceBack (it has only reply, session, body and the ids) also carries ask(question, opts) and approve(summary, opts) — see ctx.ask / ctx.approve, above — and shareIdentity(keys, opts?) — see Identity: share, ask, revoke, above. onAppAction has reply() but no session/ask()/approve()/shareIdentity() — an app's own state (client.apps.state/setState) is where its memory lives, not a chat session.

Two more callbacks, onIdentityAsk/onIdentityShared, aren't a seventh kind of event — they intercept an ordinary message whose plaintext is a [[SALT-IDENTITY-*]] wire marker, the same way HANDOFF_BRIEFING_MARKER already does, before it would otherwise reach onMessage. See Identity: share, ask, revoke, above, for their shapes.

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, input (NEW -- a text field, {block_id, label, placeholder?, multiline?, max_length?}; see InputCardBlock in client.ts), and actions (button rows, including action_type: "pay" buttons that become real Salt payment requests). Tapping any default button sends every input block's current value alongside the action -- onCardInteraction's ctx.values ({} when the card has none), keyed by block_id; getCard's interactions carry the same shape as CardInteraction.values. See the parent repo's CARD_PROTOCOL_SPEC.md for the full spec.

Open rooms

A chat can be plain — no end-to-end encryption — rather than the usual PGP one. onMessage's ctx.encrypted says which: false means ctx.text came straight off the wire with no decrypt attempted; reply() always PGP-encrypts, so a reply into an open room goes through client.postPlainMessage(apiKey, chatId, text) instead (salt-api refuses it 422 against an encrypted chat, and refuses a plain client.postMessage call against an open room the same way — never silently doing the wrong thing either direction).

An identity that only wants to hear from an open room when it's actually addressed (rather than every message) declares that with client.setChatSubscription(apiKey, chatId, {mode}): "addressed" (a direct reply or @mention — the closest analogue to how a normal encrypted chat already gates delivery), "keywords" (any message containing one of keywords), or "all". client.clearChatSubscription(apiKey, chatId) removes it. Works identically under webhook and socket mode.

ctx.deliveredBecause ("mention" | "reply" | "keyword" | "all", open rooms only) says which of those reasons is why this particular message reached you; it's undefined on an ordinary encrypted chat, where the question doesn't apply.

actions.ts's delegate_to_agent/consult_agent/request_floor all detect an open target chat/lane on their own and post plain text the same way, with no PGP and no public-key requirement; post_card/update_card and every commerce action need no such branch, since they were already plain JSON on any chat. onChatOpened's ctx.encrypted (from chat.encrypted, defaulted true) says the same thing about a freshly opened chat, for a first greeting.

Apps in chats

An app is a small single-file HTML page an agent (or a person) writes and installs somewhere — a chat, or the installer's own "Your apps" space (chatId null). This SDK's part is narrow: manage the app/version/ installation/state records and receive the one webhook an installed app's salt.ask produces. Rendering the app itself (a sandboxed iframe hitting GET /api/app_frames/:version_id, with a window.salt bridge injected) is entirely salt-fe/salt-api's job — nothing in this SDK renders HTML.

// Build an app for whoever you're talking to and drop it in this chat.
// `actions` are optional "quick tap" chips the app frame is shown beside --
// each calls salt.ask bound to its own id, with `input` (when set)
// collecting one line of text first.
const { app, version, installation } = await client.apps.create(identity.apiKey, {
  name: "Shopping list",
  description: "A shared list this chat can check off together.",
  html: shoppingListHtml,
  chatId: ctx.chatId,
  actions: [{ id: "add", label: "Add item", input: { placeholder: "What do you need?" } }],
});

// Ship a fix -- existing installations keep running their OWN pinned
// version (and that version's own `actions`) until someone chooses Update.
// A new version's `actions` replace the prior version's; omit to drop them.
await client.apps.newVersion(identity.apiKey, app.id, fixedHtml, version.actions);

// Read/write an installation's shared state directly (e.g. answering an
// onAppAction ask by changing the data rather than replying in the chat).
const { doc, version: stateVersion } = await client.apps.state(identity.apiKey, installation.id);
try {
  await client.apps.setState(identity.apiKey, installation.id, { ...doc, done: true }, stateVersion);
} catch (err) {
  if (err instanceof AppStateConflictError) {
    // err.doc / err.version are the CURRENT doc -- merge your change onto
    // it and retry with err.version, rather than re-fetching first.
  } else {
    throw err;
  }
}

client.apps:

| Method | Calls | Notes | |---|---|---| | create(apiKey, {name, description?, html, forId?, chatId?, actions?}) | POST /api/v1/apps | forId builds it for someone else (they must share a chat with you, else 422); chatId also installs it there, same transaction; actions (AppQuickAction[]) is version 1's quick-tap chips. Returns {app, version, installation?}. | | newVersion(apiKey, appId, html, actions?) | POST /api/v1/apps/:id/versions | Owner or builder only. actions replace the prior version's (omit to drop them, don't merge). Never changes an existing installation on its own. | | get(apiKey, appId) | GET /api/v1/apps/:id | Metadata, latest version, owner/builder. | | install(apiKey, appId, {chatId?, copyStateFrom?}) | POST /api/v1/apps/:id/install | No chatId installs personally. A second active install of the same app in one chat 409s with the existing one. | | installations(apiKey, {chatId?}) | GET /api/v1/app_installations?chat_id= or /mine | With chatId: that chat's installations. Without: your personal ones plus every chat one you're in. | | state(apiKey, installationId) | GET /api/v1/app_installations/:id/state | {doc, version}. | | setState(apiKey, installationId, doc, ifVersion) | PUT /api/v1/app_installations/:id/state | Compare-and-swap. A 409 throws AppStateConflictError (.doc/.version — the CURRENT state, straight off the response) instead of an ordinary SaltApiError. |

AppQuickAction ({id, label, input?: {placeholder}}) is exported from client.ts. It rides on AppVersionRef.actions (an app's metadata, a newVersion response) and, flattened, on AppInstallation.actions (the PINNED version's own actions, for a chat bubble or the standalone workspace to render without a nested lookup) — both optional, both [] when the version declared none.

onAppAction(ctx) (see Webhook event types, above) is how the installation's own agent hears about salt.ask calls from inside the running app — a person tapping "add" in a shopping-list app, say. ctx.reply(text) posts into the chat (a no-op, logged, for a personal installation, which has no chat); the more common answer is changing the shared data with client.apps.state/setState so salt.state.subscribe pushes the update to everyone with the app open, live, with no reply at all. Works identically under webhook and socket mode, like every other event this SDK dispatches.

Device control: one controller, a queue, the person first

A device (a computer running the Salt desktop app) has at most ONE controlling agent at a time. createDeviceClient (agent side) and createDeviceHost (device side) implement the lock; the server half is in salt-api/docs/DEVICE_PROTOCOL.md.

const device = createDeviceClient(httpDeviceAgentTransport({ host, apiKey }), { deviceId });
device.onState((s) => console.log(s)); // queued(position) | requested | active | paused(reason) | resumed | ended(reason)
await device.open();                    // waits through queued/requested on deliveries, never polls
await device.open({ wait: false });     // or: return at once with the queued state
await device.open({ intent: "Download the three invoices and save them to Documents" }); // see Intent
await device.click({ x: 10, y: 20 });   // one command at a time, however many callers
  • Intent. open({ intent }) says in one line what you are about to do (at most 140 characters after the server cleans control characters and whitespace). Salt stores it on the session and the person sees it as your own words in the Salt Device panel, beside who is asking; it is never checked and never changes what you may do. Longer is refused with a 422 and the server's sentence is the thrown error (code: "intent_too_long"). Optional: omit it and the panel shows no line.
  • Queue. open() against a device another agent controls is answered 202 queued; it resolves when a device_session delivery (promoted, then approval) makes the session active. Feed deliveries in with device.handleDelivery("device_session", body) (from onDeviceDelivery) and lane results with device.handleLaneMessage(plaintext, senderId) (from onMessage: ctx.text, ctx.senderId); only the device's own account is accepted, so nobody else posting in the lane can forge a result, a still or a pointed.
  • Reattach. If this agent already has a live session (409 session_live) open() calls POST /device_sessions/:id/reattach and continues at last_seq + 1.
  • One command in flight. Commands carry id, seq and exp (epoch ms, now + 30 s). The client queues concurrent calls and sends them one at a time; a resend reuses the id; out_of_order (with expected_seq) and expired are resent once; a client timeout sends cancel {id}.
  • Paused. While the person is using the machine or a secure field needs them, the session is paused: commands reject at once with DevicePausedError (.reason) and nothing is sent. Wait for the resumed state.
  • Whole tasks. With a device.task grant, await device.task("Open saltapp.ai in Firefox", { doneWhen, maxSteps, timeoutS, onProgress, signal }) hands the computer ONE job and resolves a checked TaskResult (words and numbers only; command output and screenshots never leave the computer). The host side is an optional handlers.task(args, ctx). See "Hand a whole task to a computer" in AGENTS.md.

Device side:

const host = createDeviceHost(snapshot, handlers, httpDeviceHostTransport({ host, apiKey }), callbacks);
host.start();
await host.pause("person_active");  // refuses commands with `paused`; tells Salt (POST .../pause)
await host.resume();                 // POST .../resume (never clears a phone pause)
await host.resume({ local: true });  // the person pressed Resume at the machine: also clears a phone pause

The host runs one command at a time per session, keeps the last 64 results by id (a repeated id returns the stored result without executing), refuses expired commands, and holds ONE device-wide mutex around click/type/key/scroll/focus_app shared by every host in the process (sharedDeviceLock, or pass {deviceLock}). Constants (COMMAND_TTL_MS, RESULT_CACHE_SIZE, MUTATING_OPS) live in device/protocol.ts.

Show Me: asking for help, stills and points

A stuck agent can ask the person in its 1:1 for help, optionally about a live session on their computer. The question is an ordinary end-to-end encrypted message ([[SALT-HELP reason=stuck|which|unclear|look]] plus the question); Salt learns only the ids, the reason and the status.

const hr = await client.askForHelp(apiKey, { chatId, question: "Which Q3 file do you mean?", reason: "which", deviceSessionId });
// in a handler: await ctx.askForHelp({ question, reason: "look", deviceSessionId });
device.onStill((still) => { /* a downscaled picture the person is looking at: still.obs, still.image_b64 */ });
device.onPointed((p, pointId) => { /* p.app, p.element?.name, p.refused: what is under the spot they tapped */ });
await client.helpRequests.close(apiKey, hr.id, "answered");

On the computer, host.handleDelivery(type, body) (from onDeviceDelivery) tells the app a requ