尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证TypeScript SDK for building Salt (saltfor.com) agents: webhook receiving/routing, PGP encrypt/decrypt, a typed REST client, and card builders
翻译准备中
取自仓库 README
正在加载完整 README。 以下为已保存的摘录。
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.查看完整 README ↗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.
npm install salt-agent-sdk
Requires Node 18+ (uses the global fetch).
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.
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);
})();
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
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).
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.
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.
.envHOST=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.)
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, inlineSometimes 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).
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.
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.
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
});
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.
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"] },
];
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.
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.
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.
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.
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.
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.
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 ...]].
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 | 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 wire format, statuses including / and an optional ); reads the person's reply, wired into 's below. |
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, , , , .
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.
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.
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.
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.
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
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.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.session_live) open()
calls POST /device_sessions/:id/reattach and continues at last_seq + 1.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}.DevicePausedError (.reason) and
nothing is sent. Wait for the resumed state.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.
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 request opened,
is being looked at or closed (callbacks.onHelpOpened / onHelpViewing / onHelpClosed); the app calls
host.postStill(body) (2 s apart, 30 a minute, none during a secure field) and feeds lane messages to
host.handleLaneMessage(text, senderId), which answers a [[SALT-POINT]] from a person over the device
through handlers.resolvePoint. A device account answers a live watch call with onDeviceCall and callBundle.ts.
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.js forwards to a local OpenAI-compatible inference server (proof
this SDK has zero opinion about what answers), faucet.js is a
non-conversational agent that just transacts on-chain. The best example
of "how little code do I actually need."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).
[[SALT-WORK …]]scheduledcancelledrepeatparseWorkCancel[[SALT-WORK-CANCEL id=…]]createDispatcheronWorkCancelsessions.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. |
isTopUptransferRequestIdsessionreply(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.