@jsm-mit/sultana-agent-tools-package
v0.22.0
Published
Agent tool layer for Sultana: framework-agnostic tools over the Sultana core canister wrapper — a salon owner's set (services, promos, schedule) and a customer's set (search, free times, booking), each with a read-only set that needs no identity.
Readme
sultana-agent-tools-package
@jsm-mit/sultana-agent-tools-package — the tool layer that lets an LLM agent work in Sultana by
conversation: a salon owner's set (services, promos and working hours) and a customer's set
(search, free times, booking on the customer's behalf).
It is framework-agnostic. A tool is { name, description, parameters, progress, execute }, which is what
OpenAI, Anthropic and @jsm-mit/chat-agent-package all want from a function tool, so wiring this
into an agent host is registration rather than translation. The package depends on the published
canister wrapper and nothing else — no model provider, no agent framework.
Use
import { createSalonTools, SALON_AGENT_PERSONA_PL } from "@jsm-mit/sultana-agent-tools-package";
const tools = createSalonTools({
canisterId: process.env.SULTANA_COORDINATOR_ID!,
identity, // the salon owner's identity (or, later, a granted agent principal)
salonId,
});salonId is captured at construction and is never a tool argument — a model holding these
tools cannot reach another salon's data whatever it sends.
Confirmations across turns
A host that runs the owner set in a chat keeps one ConfirmationLedger per conversation and builds
the tools again for every incoming owner message, with that message's turn:
import { ConfirmationLedger, createSalonTools, SALON_AGENT_PERSONA_PL } from "@jsm-mit/sultana-agent-tools-package";
const ledger = new ConfirmationLedger(); // one per conversation, kept in memory
// on every owner message — one agent turn:
const tools = createSalonTools({ canisterId, identity, salonId, confirmations: ledger.forTurn() });With confirmations, the two-step protocol is enforced by the tools, not only asked for by the
persona:
confirmed: falsemakes no canister write and returnsconfirmation_requiredwith asummaryand anechothat now carries aconfirmationId(c-and 8 hex digits).confirmed: truegoes through only with thatconfirmationId, the same arguments (key order does not matter, array order does;confirmed,confirmationIdandconfirmationPhraseare not compared), from a later turn, within 15 minutes, and once. Anything else isstatus: "error",code: "invalid_arguments", returned before the tool calls the canister at all:
| why | summary | |---|---| | same turn as the preview | Zgoda musi przyjść w osobnej wiadomości właścicielki — najpierw pokaż podsumowanie i poczekaj na odpowiedź. | | no id, unknown, used or expired | Brak ważnego podglądu tej zmiany — wywołaj narzędzie z confirmed=false i pokaż właścicielce podsumowanie. | | other tool or other arguments | Argumenty różnią się od pokazanego podglądu — pokaż nowy podgląd (confirmed=false). |
Every write tool's schema gets a confirmationId parameter when a ledger is passed; without one,
the schemas and the protocol are what they were in 0.2.0 (a model can still confirm in the same
breath — only the persona stops it). The id is used up right before the write, not at the first
check: a confirmation refused for a mistyped name can be sent again with the same id once the owner
has typed it right. Two parallel calls with one id cannot both write: the second is refused right before its write.
ConfirmationLedger takes { ttlMs, now, newId } for tests; createSalonToolsFromPort(port,
{ confirmations }) is the same over any port. forTurn() returns TurnConfirmations —
issue(toolName, args), check(toolName, args, id) (the verdict, the id kept) and
redeem(toolName, args, id) (the verdict, the id used up) — for a host that wants the protocol
around tools of its own.
SALON_AGENT_PERSONA_PL is written for this: every write first with confirmed=false, the
summary shown, the owner's answer awaited in her next message, then confirmed=true with the
confirmationId and the same arguments; a removal only with the name the owner typed herself; and
with read tools only, the agent says that changes need the "Salon Assistant" page open in the
panel.
Read-only set
An assistant that runs without the owner's identity takes the read-only set and its own persona:
import { createSalonReadTools, SALON_READ_PERSONA_PL } from "@jsm-mit/sultana-agent-tools-package";
const tools = createSalonReadTools({
canisterId: process.env.SULTANA_COORDINATOR_ID!,
salonId, // no identity: every call is a public query, made anonymously
});It holds list_services, list_promos and find_service_type — nothing that writes. The port
behind it is built with access: "public", so list_services calls the visitor's query: a
visitor, and so this agent, sees ACTIVE services and ACTIVE promos only (and no worker who has
not claimed their account). The two list tools say so in their description and summary, and
SALON_READ_PERSONA_PL tells the model to mention it and to send edits to the "Usługi" and
"Promocje" panel pages. No identity is needed; if a host passes the owner's anyway, the port still
drops inactive promos, so the tool keeps its promise.
createSalonReadToolsFromPort(port) is the same set over any port, for tests.
Tools
Every tool carries progress — a short Polish line in the present continuous, ending with "…",
that a chat shows while the tool runs (@jsm-mit/chat-agent-package reads Tool.progress). It is
never sent to the model. A write tool previewing with confirmed: false shows the same line.
| tool | writes | progress |
|---|---|---|
| list_services | | Sprawdzam listę usług… |
| find_service_type | | Szukam typu usługi… |
| list_workers | | Sprawdzam zespół… |
| add_service | yes | Dodaję usługę… |
| update_service | yes | Zmieniam usługę… |
| remove_service | yes | Usuwam usługę… |
| get_weekly_hours | | Sprawdzam grafik tygodniowy… |
| set_weekly_hours | yes | Ustawiam grafik… |
| get_day_schedule | | Sprawdzam grafik na ten dzień… |
| set_day_off | yes | Ustawiam dzień wolny… |
| set_busy_hours | yes | Zapisuję godziny zajęte… |
| clear_daily_override | yes | Przywracam grafik tygodniowy… |
| list_media | | Sprawdzam bibliotekę materiałów… |
| list_promos | | Sprawdzam promocje… |
| add_promo | yes | Zakładam promocję… |
| update_promo | yes | Zmieniam promocję… |
| set_promo_active | yes | Zmieniam status promocji… |
| remove_promo | yes | Kasuję promocję… |
| list_discount_codes | | Sprawdzam kody rabatowe… |
| add_discount_code | yes | Dodaję kod rabatowy… |
| remove_discount_code | yes | Usuwam kod rabatowy… |
createSalonTools returns all 21; createSalonReadTools returns list_services, list_promos
and find_service_type.
Customer tools
The second set works for ONE customer instead of one salon: it searches across all salons and books, lists and cancels visits on the customer's behalf. The host passes the customer's lent identity (an Internet Identity delegation) and the position the app sent; neither is ever a tool argument, so a model holding these tools can act for nobody else and search around nowhere else.
import { createCustomerTools, createCustomerReadTools, CUSTOMER_AGENT_PERSONA_PL } from "@jsm-mit/sultana-agent-tools-package";
const tools = session
? createCustomerTools({ canisterId, identity: session.identity, location: session.location, confirmations: ledger.forTurn() })
: createCustomerReadTools({ canisterId, location }); // no identity: search only, nothing books| tool | writes | needs identity | progress |
|---|---|---|---|
| find_service_type | | | Szukam typu usługi… |
| search_slots | | | Szukam wolnych terminów w okolicy… |
| get_salon | | | Sprawdzam salon… |
| get_free_times | | | Sprawdzam wolne godziny… |
| book_appointment | yes | yes | Rezerwuję wizytę… |
| list_my_appointments | | yes | Sprawdzam Twoje wizyty… |
| cancel_appointment | yes | yes | Odwołuję wizytę… |
search_slotswithoutdate/timeis the package's "as soon as possible" search (the next hour, else up to 24 h ahead); with them it probes from 30 min before to 60 min after the moment. Results: at most 6 salons, soonest first, each with its services and up to 6 free moments.search_slotstakes one type inserviceTypeIdor up to 30 inserviceTypeIds(both may come; they are joined and de-duplicated). Several types are ONE canister query per probe (core package ≥ 0.23.0): a service listed under two of them comes back once.search_slotssearches around the position the app sent. Optionallat+lng(both or neither) name another place — "near Kolejowa in Warsaw" — and win over the app's position; distances are then measured from that place. The package does not geocode: the model gets the numbers fromgeocode_placeof@jsm-mit/basic-tools-package, bound to the same agent. With no position and nolat/lngthe tool refuses and tells the model to ask for a place.- A model reads and sends local time:
date"YYYY-MM-DD",time"HH:mm".get_free_timesfolds the 5-minute start grid into ranges. book_appointmentpicks the first free worker, like the app, and checks the slot on the preview AND on the confirmed call — a booking the canister refuses still spends the customer's daily quota.- The public reads go out anonymously even with an identity; only booking, the visits and cancelling are signed.
- Numbers are written for the customer's region, by the tools, not by the prompt. Pass
locale(a BCP 47 tag the app runs in —"pl-PL","en-GB"; defaultpl-PL, an unknown tag falls back to it). Distances, prices and durations reach the model as ready text —distance: "8,3 km",price: "60 zł",duration: "45 min"— in the data and in every summary; there is nodistanceKm/pricePln/durationMinutesfor it to spell its own way ("8.3 km" in a Polish sentence).customerFormats(locale)is exported for a host that writes such text itself. Dates and times stayYYYY-MM-DD/HH:mm: the model sends them back as arguments. - The whole assistant follows the app's language. The language of
locale("en-GB" → English, anything else → Polish) decides three things besides the numbers: what the tools SAY (customerTexts(locale),src/customer/texts.ts— theprogresslines, the summaries and the tools' own errors, i.e. everything a host keeps in the chat as "✔ …" / "✖ …"), the language of service-type labels (unlesslanguageis given), and — throughcustomerAgentPersona(locale)— the language the model answers in. The English rule is an override ("answer in English even if the chat's history and tool results are Polish"), because a host replays both to the model.CUSTOMER_AGENT_PERSONA_PLiscustomerAgentPersona("pl-PL"). Still Polish only: tool and parameter descriptions (the model alone reads them) and the errors shared with the salon tools (argument checks, the confirmation gate, canister failures). - The host process must run in the salons' time zone (
TZ=Europe/Warsaw): the canister wrapper takes every offset from the process clock. While it does not, every time-dependent tool answersunavailableinstead of booking hours off (timeZone: nullskips the guard;hostTimeZoneProblemlets a host check at start-up).
Booksy tools (experimental, since 0.7.0; logo and cover since 0.9.0)
"Copy my salon from Booksy": three read-only tools that take the address of a PUBLIC Booksy profile and return its content in the shape Sultana's write tools take, and one write tool that copies the whole service catalogue under ONE confirmation.
const port = new IcSalonCorePort({ … });
const reader = new BooksyProfileReader(); // keep it for the conversation: one fetch per profile
const tools = [
...createSalonToolsFromPort(port, { confirmations }),
...createBooksyTools(port, { confirmations, reader, photoSink }), // no photoSink → no download tool
];createBooksyReaderTools(…) gives the three read tools alone — they need no salon and no confirmed.
| tool | what it returns |
|---|---|
| read_booksy_salon | name, description, address, coordinates, weekly opening hours (day 0 = Monday, ranges as set_weekly_hours reads them), the team, counts of services and photos |
| read_booksy_services | services ready for add_service: name, pricePln, durationMinutes, plus booksyCategory, description, workerNames, notes; optional category filter |
| download_booksy_photos | fetches photos (logo, cover, salon by default; also inspiration, service — the per-service portfolio —, staff) and hands each to the host's BooksyPhotoSink; up to 150 a call |
| import_booksy_services | write: copies the catalogue into this salon. Arguments: url, optional category, optional overrides ({name, skip?, serviceTypeIds?}), confirmed |
| import_booksy_logo | write: copies the Booksy logo into the salon's media library and sets it as the salon's THUMBNAIL; the cover is handed back untouched. Arguments: url, confirmed |
| import_booksy_cover | write: the same for the Booksy cover → the salon's COVER; the thumbnail is handed back untouched |
| import_booksy_workers | placeholder: says that copying the team is not built yet, lists the people on Booksy and who is missing in the salon; writes nothing |
The two photo tools exist only when the port can write pictures — IcSalonCorePort built with
assetsCanisterId, and an identity the assets canister accepts (a delegation must list that
canister in its targets, next to core). A salon has exactly two photo slots, and the canister
replaces both at once, so each tool reads the salon first and hands the other slot back. One
picture, one call to the assets canister: at most 1.9 MB, JPEG / PNG / WebP — anything else is
refused with a sentence, nothing is set. The asset is named Booksy · logo · <mark>; a picture
already in the library is not stored twice. The preview downloads nothing; its echo carries the
picture's mark, and a logo changed on Booksy in between makes the confirmation stale. The other
Booksy pictures (service portfolio, interior, inspirations, staff) are not copied yet.
import_booksy_services:
The model never carries the services. It names the profile; the tool reads it again itself (from the shared reader), so sixty services cost the context nothing and cannot be retyped wrong.
One confirmation.
confirmed: falsereturns the full list — name, price, time, the service type and the people it found — and an echo withexpectedCount. The confirming call is refused when the plan no longer has that many services (the profile or the salon changed in between).Service types are chosen in the tool (
src/booksy/service-type-match.ts, since 0.16.0): every type of the catalogue is scored at once — how much of ITS label (or of a synonym, trusted a little less) the name holds, earlier words weighing more, missing label words counting against; the Booksy category is read as a weaker second name, the salon's name only breaks ties ("Barber's" → the men's cut). Polish endings are cut ("brody" = "broda", never "brodawek"); a few shop words map onto the catalogue ("chłopiec"/"dziecko" → kids, "cover" → odsiwianie, "Strzyż." → strzyżenie). A type must be named by the FIRST word, by its whole label, or by the category — "Pedicure męski" gets no type rather than "Strzyżenie męskie". No match → the service is NOT copied (core refuses a service without a type); the preview lists it and asks for a type.overridessets a type by hand.People by name. A Booksy first name that matches exactly one worker of the salon is assigned; the rest are listed once under the preview.
Idempotent. A service whose name the salon already has is skipped, so an import that stopped half-way (quota, connection) is finished by running it again. Writes go 4 at a time and stop at the first failure; the result says how many were copied and how many are left.
One variant, one service. Booksy nests price-and-time variants under a service; each becomes
"Service — variant".Sultana's units. Zloty to the grosz (a comma price such as "120,00" is read too), a multiple of 5 minutes, at least 5. Every adjustment, and a "from" / varying price, is written into
notesfor the agent to tell the owner.Ids stay empty.
serviceTypeIdsandworkerIdslive in Sultana; the agent resolves them withfind_service_type(hint:booksyCategory) andlist_workers.The source is the page's
__NUXT_DATA__block (src/booksy/nuxt-payload.tsreads the devalue format); the JSON-LD block is a degraded fallback without durations, flaggedsource.degraded.The URL is untrusted: only
https://booksy.com/<locale>/<id>_…is fetched; the fragment and the query are dropped. One fetch per profile per tool set.The package touches no disk. Photos go to the sink the host passes; the sandbox's sink writes to the git-ignored
sandbox/booksy-out/<booksy id>/photos/.Reviews and customer names are never read. Reading a profile by a script may be against Booksy's terms of use — this is a trial to see whether the idea is worth a proper agreement.
readBooksySalonDraft(url) gives the whole SultanaSalonDraft without a model.
Salons around a point (no model, for scripts; since 0.15.0)
const { region, salons } = await searchBooksyAround({ latitude, longitude }); // radius 10 km, at most 10
const draft = await readBooksySalonDraft(salons[0].url);
const plan = await planBooksyServiceImport(port, draft); // services ready for addService, nothing written- Booksy's public pages have no radius search, only "category + region" listings sorted from the
region's centre. The point becomes a region first: OpenStreetMap Nominatim names the city and
the district (
reverseGeocodereplaces it), and the region ids come off the links and the listed salons of Booksy's own pages — the country page, the nearest big city, the city. - Reach: Booksy's big cities, their districts and the towns their pages name. A farther town is a
not_found(Booksy has no province listing — it answers with the country's). - Categories
BOOKSY_SALON_CATEGORIES: hair, barber, nails, brows & lashes, beautician. One listing page per category, then nearest first BY TURNS across the categories; a salon listed twice counts once. Never more thanMAX_BOOKSY_AREA_SALONS(10) — a search is not a crawl. - 1.5 s between two Booksy pages (
pausereplaces it); one search reads 3–8 pages. planBooksyServiceImportisimport_booksy_services' plan without the tool: types matched, people matched by name, already-present names skipped; the caller writes.- Sandbox:
npm run booksy-around -- <lat> <lng> [limit]prints what a search finds. listBooksyAround({ latitude, longitude, radiusKm, pages })(since 0.17.0, for the seed of a test environment): no limit — every salon within the radius, all categories merged, nearest first, once each; the region resolved once; up toMAX_BOOKSY_LISTING_PAGES(5) pages of 20 per category (?businessesPage=N), stopping when a listing runs out (businessesCount). Piaseczno, 6 categories, 3 pages: 193 salons in about a minute (one page: 47).
A host that hands these tools to an agent appends SALON_BOOKSY_PERSONA_PL to
SALON_AGENT_PERSONA_PL. One BooksyProfileReader may serve the whole host: profiles are public,
it remembers a profile for ten minutes and keeps the last fifty.
What the tools guarantee
executenever throws. Every call returns{status: "ok" | "confirmation_required" | "error"}so an agent can read a failure and talk about it.- Confirmation is a schema field, not a prompt rule. Called with
confirmed: false, a write tool makes no canister write and returns a sentence built from resolved arguments — worker names and service-type labels already looked up — plus the arguments to send back inecho. A persona can be talked out of a rule; a required field cannot. With aConfirmationLedger(above) the confirmation must also come from a later turn, with the preview's id. - Irreversible operations need the entity's own name typed back in
confirmationPhrase.remove_serviceandremove_promoleave the name OUT ofecho, and their summary asks the model to have the owner write it: a model that just sends the echo back deletes nothing. - A partial edit is merged — at write time.
updateSalonServicereplaces the whole record on the canister, so "just change the price" would otherwise unassign the team. The preview summary shows the merged record, butupdate_service'sechocarries onlysalonServiceIdand the fields the model sent; the confirmed call reads the record again and merges then, so a change made in the panel between preview and confirmation is kept. - Units are fixed at the boundary: a tool takes and shows złote with at most two decimals, the
canister keeps grosze (
src/money.ts, converted only in the ports); duration is minutes rounded up to a multiple of 5 (the availability grid), and the tool says when it rounded. - Service-type ids come from the catalogue.
find_service_typeuses the same matcher as both frontends, and a write with an unknown id is refused before the canister is called.
Scripts
| script | what it does |
|---|---|
| npm run build | tsc for dist/, then a type-check over tests and sandbox |
| npm test | unit tests against an in-memory salon — no replica, no network |
| npm run whoami | setup check: the identity, its salons, the service-type catalogue |
| npm run sandbox -- --fake | talk to the tools against an in-memory salon |
| npm run sandbox | the same conversation against a real canister |
| npm run booksy -- <profile url> | the Booksy reader alone: prints the draft, writes sandbox/booksy-out/<id>/draft.json and the photos (--no-photos, --all-photos) — no model, no canister |
| npm run booksy -- <profile url> --import | import_booksy_services against the in-memory salon: the preview, then the confirmed import |
| npm run test-tools | the round trip against a live canister (creates its own salon) |
| npm run test-customer-tools | the customer's round trip against a live, seeded canister: search, book, list, cancel (a fresh identity per run) |
| npm run publish-public | publishes to npm from master — see below |
Testing by talking
npm run sandbox -- --fake is the loop to iterate in: a real model, real tool calls, an in-memory
salon. Every call is printed with its arguments and its result, so a tool description that misleads
the model shows up immediately — and a wrong answer costs no cycles and no daily action quota. The
sandbox builds the tools again for every line you type, with a turn of one ConfirmationLedger,
as a host does — so the confirmation id and the next-message rule are in play there too.
Drop --fake to run the same conversation against a canister with the owner's identity
(OWNER1_PEM). Start with npm run whoami: it prints the principal the PEM makes you, the salons
that identity owns on that canister — one of which is your SULTANA_SALON_ID — and whether the
service-type catalogue is seeded at all. npm run test-tools is the deterministic version of that: every write tool against
a live canister, each verified by reading it back. Both talk to a canister, so a human starts
them, not Claude.
Publishing
Bump version in package.json by hand, then npm run publish-public from master. The script
refuses a master that differs from origin/master, any uncommitted change besides the version
bump, and a version already on the registry. It builds from a clean dist/, runs the unit
tests, publishes with --access public, and only then commits "Version bump to " and
pushes master — so a failed publish leaves nothing claiming a release. A version whose bump is
already committed publishes without a new commit.
There is no canister-commit stamp: this package wraps no canister of its own. The tarball holds
dist/, README.md and package.json — no tests, no sandbox, no .env.
