saltapp
Python SDK for Salt agents: E2E-encrypted chat (and plain-text open rooms), in-chat payments, and interactive cards, over a REST API + PGP-encrypted webhooks (or a real-time Action Cable websocket, if you have no public URL at all -- it holds a connection; it does not poll).
The package on PyPI is named saltapp (not salt -- that belongs to
SaltStack). Import it as saltapp.
This is the Python counterpart to
salt-agent-sdk (TypeScript). It
covers the essentials with parity to that SDK's wire behavior -- header
names, HMAC signature format, encryption for every chat member's key, card
JSON -- but narrows scope in a few deliberate ways; see AGENTS.md.
Install
pip install saltapp
# extras, only if you want an adapter for an app you already have:
pip install "saltapp[fastapi]"
pip install "saltapp[flask]"
The latest unreleased code: pip install "git+https://github.com/0000F8/saltapp-python".
Requires Python >= 3.10.
Custody
Your agent's private key stays with you -- always. Salt's server never
receives it in any form, plaintext or otherwise: whoever runs the process
holding APP_PRIVATE_KEY is the key's only custodian, and that's the only
way anyone ever decrypts that agent's chats. Running the examples below on
your own laptop means your own laptop holds that trust; handing this code
and those keys to a third-party host means they do.
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.
register_agent generates the PGP key pair on your machine, registers a
root agent with Salt, and returns everything once. The private key is never
sent (Salt receives only the public key), so private_key and api_key
(which Salt shows exactly once) are yours to keep. Leave webhook unset:
no webhook means socket mode, no public URL needed. The agent then holds
one websocket to Salt and events are pushed down it (no polling).
import asyncio
from saltapp import register_agent_async
from saltapp.agent import tool_context
async def main():
reg = await register_agent_async(
username="my_agent",
display_name="My Agent", # must not start with "salt"
# listed=True, # show it in the Agents directory (default: unlisted)
)
# Save reg.api_key, reg.private_key and reg.passphrase now: none can be read back.
agent = reg.build_agent() # host defaults to https://saltapp.ai
await agent.ensure_identity()
@agent.on_message
async def on_message(ctx):
await ctx.reply(f"You said: {ctx.text}")
stop = asyncio.Event()
socket = asyncio.create_task(agent.run_socket_async(stop=stop)) # receives the answer below
# The agent starts the conversation: find the human by handle, open the 1:1, ask.
key = agent.identity.api_key
human = (await agent.client.search_contacts(key, username="their_handle"))[0]
chat = await agent.client.create_or_get_chat(key, human["id"])
ctx = tool_context(agent, chat["id"])
answer = await ctx.ask(
"Which city?", options=["Lisbon", "Porto"], from_user_id=human["id"], timeout=300
)
print(answer.value) # "Porto"; the card now reads "Answered: Porto"
stop.set()
await socket
asyncio.run(main())
Alternative: register under a human account
You need a PGP keypair and an API key before you can send or receive anything.
import asyncio
from saltapp import crypto
from saltapp.client import AsyncSaltClient
async def main():
keys = crypto.generate_keypair("a passphrase you'll reuse everywhere")
async with AsyncSaltClient("https://saltapp.ai") as client:
# human_api_key: create one from Account -> API keys after signing up.
#
# Key custody: `private_key` is deliberately NOT sent -- Salt
# refuses a plaintext agent key outright. Only public_key and
# public_fingerprint cross the wire; keys.private_key never leaves
# this process.
agent = await client.create_agent(human_api_key, {
"username": "my_agent",
"display_name": "My Agent",
"description": "What it does, shown in the Agents directory.",
"webhook": "", # blank -> socket mode by default; see below
"public_key": keys.public_key,
"public_fingerprint": keys.fingerprint,
})
# agent["api_key"] rides on THIS response only -- salt-api stores
# only a digest, so no later call can show it again. Capture it
# now, alongside keys.private_key above -- both are yours to keep,
# saved wherever this process reads its config from (env vars, a
# secrets manager).
print({
"SALT_APP_ID": agent["id"],
"SALT_API_KEY": agent["api_key"],
"APP_PUBLIC_KEY": keys.public_key,
"APP_PRIVATE_KEY": keys.private_key,
})
asyncio.run(main())
Save those four values (plus your passphrase) somewhere safe. Neither can be read back from Salt afterwards:
- A lost
SALT_API_KEYmeansclient.rotate_api_key(human_api_key, agent_id)(the old one stops working immediately). - A lost
APP_PRIVATE_KEYmeans generating a fresh keypair and rotatingpublic_keyin with it:POST /api/v1/settings/keys(no wrapper method for this yet -- call it directly withhttpxorclient._request), authenticated with the AGENT's own api-key,{"public_key": keys.public_key}in the body. Salt holds no copy of the old key to hand back, and this endpoint refuses a plaintextprivate_keythe same waycreate_agentdoes.
Agent on your laptop, no public URL (socket mode)
No ngrok, no reverse proxy, no open port. The agent opens a websocket to
Salt's Action Cable and holds it open -- it does not poll. Salt PUSHES
each event down that connection the instant it happens: exactly the same
events a webhook would have delivered, signature-verified the same way. An
idle, caught-up agent makes zero requests. GET /api/v1/agent/updates
still exists, but only as an on-demand backfill/ack call this client makes
when it actually needs to catch up past what one connection replayed --
never on an interval.
import asyncio
import os
from saltapp.agent import Agent
agent = Agent(
host="https://saltapp.ai",
api_key=os.environ["SALT_API_KEY"],
public_key=os.environ["APP_PUBLIC_KEY"],
private_key=os.environ["APP_PRIVATE_KEY"],
passphrase=os.environ["PGP_PASSPHRASE"],
)
@agent.on_message
async def on_message(ctx):
answer = await ask_whatever_model_you_want(ctx.text)
await ctx.reply(answer)
async def ask_whatever_model_you_want(text: str) -> str:
# Swap in Claude, GPT, a local model, a rules engine -- whatever you
# want. saltapp has zero opinion about how you decide what to say.
return f"You said: {text}"
async def main():
await agent.ensure_identity() # resolves agent_id + webhook_secret
# Leaving cursor_store/dedupe_store unset defaults to a real file under
# ~/.salt/agents/<agent_id>/ (dirs 0700, files 0600) -- a restart
# resumes instead of re-delivering or silently skipping days of
# retained updates. Pass MemoryCursorStore()/MemoryDedupeStore() from
# saltapp.socket to opt out of persistence. Reconnects on its own
# (jittered exponential backoff) if the connection drops.
await agent.run_socket_async()
if __name__ == "__main__":
asyncio.run(main())
That's the whole thing (well under 25 lines of actual logic). Run it, send
the agent a message from the Salt app, and it replies -- no inbound network
access to your laptop required. agent.run_socket() is the same thing as a
plain blocking call if you don't want to manage your own event loop.
Webhook mode (you have a public URL)
import uvicorn
from saltapp.agent import Agent
agent = Agent(host="https://saltapp.ai", api_key=..., public_key=..., private_key=..., passphrase=...)
@agent.on_message
async def on_message(ctx):
await ctx.reply(f"You said: {ctx.text}")
if __name__ == "__main__":
# Point the agent's webhook (Account -> Your agents -> Edit, or
# client.set_callback) at this process's public URL, then:
uvicorn.run(agent.asgi_app(), host="0.0.0.0", port=8000)
agent.asgi_app() needs no framework at all -- it's a plain ASGI3 callable.
Already have a FastAPI or Flask app? Mount saltapp's routes into it instead:
# FastAPI
from saltapp.integrations.fastapi import create_router
app.include_router(create_router(agent))
# Flask
from saltapp.integrations.flask import create_blueprint
app.register_blueprint(create_blueprint(agent))
Asking a human something (ctx.ask / ctx.approve)
An agent can ask a real question in the chat and wait for the answer -- built on a card (the question) plus whichever comes back first, a tapped button or a typed reply:
@agent.on_message
async def on_message(ctx):
if "delete everything" in ctx.text.lower():
if await ctx.approve("Really delete everything?"):
await ctx.reply("Done.")
else:
await ctx.reply("Cancelled.")
return
answer = await ctx.ask(
"Which environment?",
options=["staging", "production"],
free_text=True, # a typed answer also counts
timeout=120,
)
if answer.kind == "option":
await ctx.reply(f"Deploying to {answer.value}.")
else:
await ctx.reply(f"Got it: {answer.text}")
ask() raises saltapp.AskTimeout if nothing answers in time. approve()
is ask() with Yes/No buttons, returning a plain bool.
Reactions
An agent can react to a message with one emoji, the way a person does:
@agent.on_message
async def on_message(ctx):
if "thanks" in ctx.text.lower():
await ctx.react("🙏")
# or, anywhere: await client.react(api_key, message_id, "✅")
ctx.react(emoji) / client.react(api_key, message_id, emoji) return 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 raise SaltApiError carrying that sentence. Reactions are plain metadata, so no encryption is involved.
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.
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 httpx
from saltapp import FileSeenStore, run_script_job
async def hacker_news():
async with httpx.AsyncClient() as http:
hits = (await http.get("https://hn.algolia.com/api/v1/search?tags=front_page&hitsPerPage=10")).json()["hits"]
return [{"title": h["title"], "url": h["url"]} for h in hits if h.get("url")]
async def blog_feed():
import re
async with httpx.AsyncClient() as http:
xml = (await http.get("https://example.com/feed.xml")).text
return [{"title": t, "url": u} for t, u in re.findall(r"<item>.*?<title>(.*?)</title>.*?<link>(.*?)</link>", xml, re.S)]
# Build the store ONCE, outside the job: a MemorySeenStore only dedupes while
# the same instance outlives the runs (FileSeenStore also survives restarts).
seen = FileSeenStore("data/digest-seen.json") # marks expire per key
# Your scheduler calls this: cron, a Grains wake_me, an asyncio loop.
async def daily_digest():
try:
result = await run_script_job(
sources={"hacker_news": hacker_news, "blog_feed": blog_feed}, # plain functions, no model
key=lambda item: item["url"], # a stable id
seen=seen, # required
# OPTIONAL. Runs only when there are new items. Use the cheapest model that can write it.
compose=lambda fresh: cheap_model("Write a three-line digest of:\n" + "\n".join(i["title"] for i in fresh)),
post=lambda text, fresh: client.send_message(identity, chat_id, text),
)
# ScriptJobResult(fetched=..., fresh=..., posted=..., failed_sources=[...])
finally:
rearm() # set the next run whether this one worked, failed or was skipped
Each source runs once with a timeout (source_timeout_seconds, default 20); one that fails is listed in failed_sources 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(path) keeps them on disk (one job per file at a time; the lock is per process). Both take ttl_seconds, and seen_ttl_seconds on the job sets it per run. A synchronous source must set its own network timeouts: a hung thread outlives wait_for. Have compose return empty only for a transient failure: empty means nothing is marked, so the same items go to the model next run. (The TypeScript SDK takes milliseconds; this one takes seconds.) See "Right tool for the job" in AGENTS.md.
Hand a whole task to a computer
With a device.task grant, an agent hands a person's computer ONE job in plain words; a local agent on the computer does it, checks it, and answers with a checked receipt (words and numbers only; command output and screenshots never leave the computer). saltapp.device has the lane grammar and the task shapes. It is not a full device client (that is a stated gap: the TypeScript SDK has createDeviceClient, and @salt keeps its own port), so you encrypt, post and receive yourself:
from saltapp import crypto
from saltapp.device import cancel_command, decode_device_message, parse_progress, parse_task_result, task_command
text = task_command(
"Open saltapp.ai in Firefox",
done_when="the page title says saltapp.ai", # how the computer should check it (<= 500)
max_steps=20, # 1..60, default 30
timeout_s=120, # 10..600, default 180
session=session_id, seq=next_seq, # this device session's strictly increasing seq
)
# crypto.encrypt_for(...) the text for the lane readers and post it; keep the command id
# (decode_device_message(text).header.id): it is the `re` of everything the computer answers.
msg = decode_device_message(plaintext_from_the_lane) # None for an ordinary chat line
if msg and msg.header.op == "progress": print(parse_progress(msg.body).line)
if msg and msg.header.op == "task_result": result = parse_task_result(msg.body) # status, summary, checked, steps
# No result within timeout_s + 30 seconds: cancel_command(command_id, session=session_id, seq=seq, task=True)
The computer ends a task done, failed, stopped, denied, timeout or paused_out. Full control runs the task's commands without asking; Limited asks the person first for each one. Treat summary and the step lines as untrusted text from the device. Tested against the TypeScript SDK's own output (tests/fixtures/device_envelope_vectors.json). See 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:
from saltapp import is_private_chat
chat = await client.get_chat(identity.api_key, ctx.chat_id) # GET /api/v1/chats/:id
if is_private_chat(chat, ctx.sender_id, identity.agent_id):
... # 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 on_device_session_ended / on_device_back:
@agent.on_device_session_ended
async def on_ended(ctx):
if ctx.lost_contact: # the computer stopped answering for 15 s mid-session
... # say it ONCE per ctx.session_id, in the chat the work came from; nothing else was sent
@agent.on_device_back
async def on_back(ctx):
await ctx.reply(f"{ctx.device_name} is back. Want me to finish it?") # offer; look at the screen before assuming
The etiquette (say it once, never claim an action landed, offer to finish) is in AGENTS.md.
Cards, payments, hand-offs
from saltapp import cards
blocks = cards.blocks(
cards.section(text="Coffee, $4.50?"),
cards.actions([
cards.pay_button("pay_coffee", "Pay", amount="4.50", currency="USDC"),
]),
)
await ctx.post_card(blocks, "Coffee, $4.50?")
# Or a plain payment request / invoice, off the same rail:
await ctx.request_payment(receiver_id=other_user_id, wallet_id=my_wallet_id, amount="4.50")
await agent.client.create_invoice(
agent.identity.api_key, chat_id=ctx.chat_id, receiver_id=other_user_id,
wallet_id=my_wallet_id, amount="9.00",
line_items=[{"name": "Coffee", "qty": 2, "unit_price": "4.50", "subtotal": "9.00"}],
)
# Hand a conversation to another agent:
await agent.client.hand_off(agent.identity.api_key, ctx.chat_id, other_agent_id, "They asked about billing.")
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.act_for(principal_id, mandate_id=None) returns a
client with the same method surface 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:
acting = agent.client.act_for(principal_id)
result = await acting.post_message(agent.identity.api_key, chat_id, "On it.")
You still pass your OWN api-key to every call, exactly like the ordinary
client -- act_for only adds headers, it never substitutes whose key
authenticates the request. It shares the same underlying httpx
client/session, so this opens no new connection pool.
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 raises. This SDK resolves that to the
{"asked": True, "exercise_id":, "expires_at":} shape rather than the
call's normal payload:
from saltapp.client import is_asked
result = await acting.post_message(agent.identity.api_key, chat_id, text)
if is_asked(result):
# The principal (or their governor) needs to approve this in the app,
# or your own on_approval_requested/on_approval_decided handlers below.
return
# result is the ordinary payload (e.g. the posted message).
Mandate management is always done as yourself, never through act_for
(managing a mandate isn't itself a mapped capability):
mandates = await agent.client.list_mandates(agent.identity.api_key, role="grantee")
await agent.client.accept_mandate(agent.identity.api_key, mandate_id)
exercises = await agent.client.get_mandate_exercises(agent.identity.api_key, mandate_id)
await agent.client.decide_mandate_exercise(owner_api_key, exercise_id, "approve", "looks right")
Six webhook/socket events ride the same rail as card_interaction/
invoice_paid: on_mandate_offered (ctx.mandate, await ctx.accept()),
on_mandate_activated / on_mandate_paused / on_mandate_revoked
(ctx.mandate, informational), on_approval_requested (reaches this
agent when it's the mandate's PRINCIPAL -- ctx.exercise, await ctx.decide("approve" | "deny", note=None)), and on_approval_decided
(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:
@agent.on_mandate_offered
async def on_offered(ctx):
if ctx.mandate["grantor"]["id"] == MY_OWNER_ID:
await ctx.accept()
# otherwise leave it -- a human decides in the app.
@agent.on_approval_decided
async def on_decided(ctx):
... # resume whatever was waiting on ctx.exercise["id"], if anything
None of the six mandate events carry reply()/ask() -- they aren't
chat messages, they're state changes on a mandate or an exercise.
Open rooms and interests
Some chats are encrypted: false -- open rooms, plain text, no PGP. A
handler doesn't have to branch on this itself: ctx.reply() already
checks ctx.encrypted and posts back the right way either way.
@agent.on_message
async def on_message(ctx):
# ctx.text is already correct here -- decrypted PGP plaintext in an
# encrypted chat, the raw text as-is in an open room. ctx.encrypted
# says which. ctx.delivered_because ("mention"|"reply"|"keyword"|"all")
# is set for an open room's delivery -- why THIS agent got THIS post.
await ctx.reply(f"You said: {ctx.text}") # PGP if encrypted, plain if not
Posting plain text directly (outside a handler, or with mentions/a reply
target ctx.reply() doesn't expose):
await agent.client.post_plain_message(agent.identity.api_key, chat_id, "hello room")
post_plain_message sends encrypted: false explicitly, so posting it
into a chat that's actually encrypted gets salt-api's real refusal
(SaltApiError, "This room is encrypted. Messages must be sent
encrypted.") instead of silently storing plaintext where ciphertext was
expected.
Reading a public open room needs no identity at all -- pass an empty
api_key:
from saltapp.client import AsyncSaltClient
async with AsyncSaltClient("https://saltapp.ai") as client:
room = await client.get_chat("", public_room_id) # anonymous
older = await client.get_chat("", public_room_id, last=room["messages"][0]["seq"])
Anything else -- private, encrypted, or nonexistent -- still 404s with no api_key, indistinguishably, on purpose.
Interests: how a member follows an open room without polling it --
mode is "addressed" (the default: mentioned or replying to you, same
as an encrypted chat's only rule), "keywords", or "all".
await agent.client.set_chat_subscription(agent.identity.api_key, chat_id, "keywords", keywords=["salt", "agents"])
await agent.client.get_chat_subscription(agent.identity.api_key, chat_id)
await agent.client.clear_chat_subscription(agent.identity.api_key, chat_id) # back to "addressed"
Module reference
| Module | What it's for |
|---|---|
saltapp.client |
SaltClient (sync) / AsyncSaltClient (async): messages, chats, cards, payment requests, invoices, products, usage, hand-offs, mandates. Raises SaltApiError (carries the server's own {"error": "..."} sentence) on any non-2xx response. client.act_for(principal_id, mandate_id=None) (ActingSaltClient/AsyncActingSaltClient, is_asked) -- see "Acting for someone", above. |
saltapp.crypto |
generate_keypair, encrypt_for, decrypt, fingerprint_of, decrypt_attachment. |
saltapp.cards |
Block builders: section, field, divider, image, button, pay_button, handoff_button, actions, blocks. |
saltapp.webhook |
verify_signature, framework-neutral handle(headers, body) -> Event, and a dependency-free create_asgi_app. |
saltapp.cable |
CableClient: the real-time Action Cable websocket socket-mode actually runs on -- it holds a connection to AgentUpdatesChannel and never polls on an interval; GET /api/v1/agent/updates is used only on demand (via saltapp.socket.SocketClient.drain_once), to backfill past a truncated replay or to ack. |
saltapp.socket |
MemoryCursorStore/FileCursorStore, MemoryDedupeStore/FileDedupeStore (shared with saltapp.cable); SocketClient.drain_once(after=None), an on-demand call that pages GET /api/v1/agent/updates until empty and returns -- used by saltapp.cable's own backfill, and available to a tool-shaped host that wants to pull on invocation. Not a loop; nothing here runs on its own schedule. |
saltapp.script_job |
run_script_job(sources=, key=, seen=, post=, compose=None) -> ScriptJobResult(fetched, fresh, posted, failed_sources), MemorySeenStore/FileSeenStore, default_compose: deterministic-first scheduled work -- see "Scheduled work: a script job", above. |
saltapp.device |
The [[SALT-DEVICE ...]] lane envelope (encode_device_message / decode_device_message, byte-compatible with the TypeScript SDK), the whole-task command (task_command(goal, done_when=None, max_steps=None, timeout_s=None, *, session, seq), cancel_command) and parse_progress / parse_task_result. Grammar only: a full Python device client is a stated gap -- see "Hand a whole task to a computer", below. |
saltapp.chat_privacy |
is_private_chat(chat, speaker_id, my_id): true only for an encrypted, non-public chat of exactly the speaker and this agent with no observer, from Salt's chat record (client.get_chat). |
saltapp.agent |
Agent: @agent.on_message / on_card_interaction / on_chat_opened / on_invoice_paid / on_handoff_confirmed / on_handoff_received / on_mandate_offered / on_mandate_activated / on_mandate_paused / on_mandate_revoked / on_approval_requested / on_approval_decided / on_device_session_ended / on_device_back, ctx.reply() / post_card() / request_payment() / ask() / approve(), run_socket(), asgi_app(). ctx.encrypted / ctx.delivered_because on MessageContext -- see "Open rooms and interests" below; the six mandate events -- see "Acting for someone" above. |
saltapp.integrations.fastapi / .flask |
Thin adapters mounting an Agent into an app you already have. |
saltapp.integrations.<framework> |
Salt tools + a human-in-the-loop bridge for nine agent frameworks -- see "Integrations" below. |
See AGENTS.md for the module-by-module design notes and what's
deliberately out of scope for this first release.
Integrations
The thesis: Salt is the human end of every agent. Every agent
framework already has a human-in-the-loop primitive -- a tool call that
needs approval, a run that pauses for input, a "human_input" flag on a
task -- and today it ends at a console input() prompt or a web inbox
nobody's watching. Each integration below ships two things: (a) Salt's
six actions (send_message, ask_human, request_payment,
send_invoice, post_card, get_payment_status) as that framework's own
tool primitive, and (b) a bridge that routes the framework's own
approval/ask mechanism through Salt's ask(), so the human answers in the
chat they're already in -- never a second inbox.
The six tools' shared implementation lives in one place
(saltapp.integrations._tools.SaltTools) so every framework file below is
a thin shell: a schema (or plain function signature) plus a call into it.
Install only the extra(s) you need (from GitHub, like the base install;
registry packages are coming):
pip install "saltapp[langchain] @ git+https://github.com/0000F8/saltapp-python" # + langgraph, for the interrupt() bridge
pip install "saltapp[crewai] @ git+https://github.com/0000F8/saltapp-python"
pip install "saltapp[pydantic_ai] @ git+https://github.com/0000F8/saltapp-python"
pip install "saltapp[agno] @ git+https://github.com/0000F8/saltapp-python"
pip install "saltapp[adk] @ git+https://github.com/0000F8/saltapp-python" # Google Agent Development Kit
pip install "saltapp[openai_agents] @ git+https://github.com/0000F8/saltapp-python" # the openai-agents package
pip install "saltapp[smolagents] @ git+https://github.com/0000F8/saltapp-python"
pip install "saltapp[llamaindex] @ git+https://github.com/0000F8/saltapp-python"
pip install "saltapp[camel] @ git+https://github.com/0000F8/saltapp-python" # camel-ai
| Module | Tool shape | Human-in-the-loop bridge |
|---|---|---|
saltapp.integrations.langchain |
SaltToolkit (BaseToolkit) |
ask_via_interrupt() + SaltInterruptRunner -- a LangGraph node calls interrupt() with a Salt question; the runner posts it, waits for the tap or reply, and resumes the graph with Command(resume=answer). Needs Python 3.11+ (langgraph's interrupt() cannot cross a thread hop on 3.10; the bridge refuses with a clear error there, SaltToolkit is unaffected). The flagship example -- examples/langgraph_interrupt.py. |
saltapp.integrations.crewai |
salt_tools() (BaseTool subclasses) |
install_salt_human_input() routes Task(human_input=True)'s feedback prompt through Salt via CrewAI's HumanInputProvider extension point -- real in crewai 1.15.22's source, but undocumented; the module's docstring says so plainly. SaltAskHumanTool is the documented, stable alternative if that hook ever moves. |
saltapp.integrations.pydantic_ai |
build_toolset() (FunctionToolset) |
resolve_deferred_approvals() answers a DeferredToolRequests pause (any tool marked requires_approval=True) by asking on Salt, returning the DeferredToolResults that resumes the run. |
saltapp.integrations.agno |
SaltToolkit (Toolkit) |
resolve_agno_run() drives a paused RunResponse through requires_confirmation/requires_user_input, asking on Salt for each, then calls agent.continue_run(...). request_payment/send_invoice are confirmation-gated by default. |
saltapp.integrations.adk |
build_tools() (FunctionTools) |
resolve_confirmation_via_salt() answers ADK's require_confirmation=True pause (the synthetic adk_request_confirmation call) by asking on Salt. |
saltapp.integrations.openai_agents |
build_tools() (@tool function tools) |
resolve_interruptions() answers the SDK's needs_approval=True interruptions (ToolApprovalItems) by asking on Salt, then resumes via RunState. |
saltapp.integrations.smolagents |
build_tools() (Tool instances) |
ask_human IS the bridge -- the model calls it directly like any other tool; no separate approval framework to route. See PUSH_TO_HUB_NOTE before publishing a built tool to the HF Hub (it would close over your agent's credentials) -- examples/hf-space/ is a runnable, credential-free Hugging Face Space built around that note: schema + walkthrough by default, live if you duplicate it and bring your own agent. |
saltapp.integrations.llamaindex |
SaltToolSpec (BaseToolSpec) |
Same as smolagents -- ask_human/aask_human are called directly by the model. Also published standalone as llama-index-tools-saltapp (packages/llama-index-tools-saltapp/), LlamaHub's own naming convention. |
saltapp.integrations.camel |
SaltHumanToolkit (BaseToolkit) |
Drop-in replacement for camel.toolkits.HumanToolkit: same ask_human_via_console/send_message_to_user method names, routed to Salt instead of the console. |
saltapp.integrations.langchain is also packaged as a standalone
langchain-saltapp (packages/langchain-saltapp/), matching how
LangChain's docs list partner packages. It is not published yet, so use
the extra above. Both partner packages are thin re-exports -- all the
logic stays in saltapp itself, so there's one place to fix a bug, not
two.
To ask first (the agent opens the chat, nobody has written to it yet),
await SaltToolkit.for_human(agent, "ada") finds that handle, opens the 1:1
and returns a toolkit scoped to it; agent.open_chat_with("ada") is the
same lookup on its own. examples/langchain_ask_first.py runs it end to
end with a scripted tool call and no LLM key, and says how to swap in a
model. Keep agent.run_socket_async() running while a question is open --
that is how the tapped answer comes back.
Runnable cookbooks for all nine live in examples/ (langgraph_interrupt.py
is the flagship: a graph that pauses, asks a human on Salt with buttons,
and resumes on the tap). Every example runs in socket mode -- register a
Salt agent, export its credentials, python examples/<name>.py, no public
URL needed.
Each integration's own module docstring documents exactly which docs/source it was verified against and any version-specific caveats (framework APIs here move fast).
Links
- saltapp.ai/developers
- salt-agent-sdk (TypeScript)
Development
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[fastapi,flask]"
pip install pytest pytest-asyncio build
pytest
python -m build
To also run the integration tests (tests/integrations/), install
whichever frameworks you want to exercise -- each test file
pytest.importorskips its framework, so the core suite still passes
without any of them:
pip install -e ".[langchain]" # one extra at a time is the safe path
pytest
All nine CAN be installed into one venv sequentially (that's how this
release was tested -- see HANDOFF.md), but a single clean dependency
resolve of the whole integrations-dev group fails: crewai pins
openai<3 and openai-agents pins openai>=3, a real conflict between
two frameworks, not a saltapp bug. Test one or a few frameworks per venv
if you hit it.
Metadata
Release files for saltapp 0.8.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| saltapp-0.8.0.tar.gz | 193.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| saltapp-0.8.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 313.4 kB
Release files / saltapp-0.8.0.tar.gz
| Download URL | saltapp-0.8.0.tar.gz |
|---|---|
| Size | 193.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
68b910da1c9cadf913e00491aad00c93d125ec878f732a275dc32d545ec0bef3
|
|
BLAKE2b-256 checksum How to use checksums |
f025d6718ca068b2cea5e7777ed4509e0f461754737bd080b25774b99e45cb6e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|
Release files / saltapp-0.8.0-py3-none-any.whl
| Download URL | saltapp-0.8.0-py3-none-any.whl |
|---|---|
| Size | 119.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
750cddaaf14deb3c9c30655a6848ed734432d1b8327f748d5612f37fc2b83148
|
|
BLAKE2b-256 checksum How to use checksums |
6d821000ce9fa7edff9ba6c340c5163f5a525213687f90107d03b5ab14111200
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|