npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

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

About

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

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

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

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

Open Software & Tools

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

© 2026 – Pkg Stats / Ryan Hefner

@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:

  1. confirmed: false makes no canister write and returns confirmation_required with a summary and an echo that now carries a confirmationId (c- and 8 hex digits).
  2. confirmed: true goes through only with that confirmationId, the same arguments (key order does not matter, array order does; confirmed, confirmationId and confirmationPhrase are not compared), from a later turn, within 15 minutes, and once. Anything else is status: "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_slots without date/time is 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_slots takes one type in serviceTypeId or up to 30 in serviceTypeIds (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_slots searches around the position the app sent. Optional lat + 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 from geocode_place of @jsm-mit/basic-tools-package, bound to the same agent. With no position and no lat/lng the 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_times folds the 5-minute start grid into ranges.
  • book_appointment picks 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"; default pl-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 no distanceKm / pricePln / durationMinutes for 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 stay YYYY-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 — the progress lines, the summaries and the tools' own errors, i.e. everything a host keeps in the chat as "✔ …" / "✖ …"), the language of service-type labels (unless language is given), and — through customerAgentPersona(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_PL is customerAgentPersona("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 answers unavailable instead of booking hours off (timeZone: null skips the guard; hostTimeZoneProblem lets 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: false returns the full list — name, price, time, the service type and the people it found — and an echo with expectedCount. 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. overrides sets 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 notes for the agent to tell the owner.

  • Ids stay empty. serviceTypeIds and workerIds live in Sultana; the agent resolves them with find_service_type (hint: booksyCategory) and list_workers.

  • The source is the page's __NUXT_DATA__ block (src/booksy/nuxt-payload.ts reads the devalue format); the JSON-LD block is a degraded fallback without durations, flagged source.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 (reverseGeocode replaces 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 than MAX_BOOKSY_AREA_SALONS (10) — a search is not a crawl.
  • 1.5 s between two Booksy pages (pause replaces it); one search reads 3–8 pages.
  • planBooksyServiceImport is import_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 to MAX_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

  • execute never 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 in echo. A persona can be talked out of a rule; a required field cannot. With a ConfirmationLedger (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_service and remove_promo leave the name OUT of echo, 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. updateSalonService replaces the whole record on the canister, so "just change the price" would otherwise unassign the team. The preview summary shows the merged record, but update_service's echo carries only salonServiceId and 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_type uses 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.