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

@ai-ecoverse/slicc-agent

v7.22.0

Published

SLICC's agent core on pi durable: an agent worker, session storage, remoting and pi extensions

Readme

slicc-agent

SLICC's agent core, built on pi's durable harness. It runs in a dedicated worker next to slicc-kernel and is shown by slicc-spectrum. The plan is in ai-ecoverse/slicc-agent#2.

The package ships unbundled ESM: dist/ holds one file per source file and imports pi's packages by name, so slicc-bios installs it from its lockfile like any other package, and pnpm upgrade @ai-ecoverse/slicc-agent updates it in place.

Use

In the agent's dedicated worker:

import { hostAgent, openAgent, openOpfsSqliteStorage, serveConnections } from '@ai-ecoverse/slicc-agent';

const storage = await openOpfsSqliteStorage();
serveConnections(self, openAgent({ models, model, storage }).then((agent) => hostAgent(agent)));

In the page:

import { startAgent } from '@ai-ecoverse/slicc-agent/page';

const owner = await startAgent({ worker: () => new Worker(workerUrl, { type: 'module' }) });
const agent = await owner.connect();
agent.transcript.subscribe((view) => render(view));
await agent.prompt('What is in /home?');
  • openAgent({ models, model, storage?, registry?, settings?, clock? }) opens a durable Harness (in memory unless storage is given) and its root conversation, and resumes work a previous worker left unfinished. clock ({ now, setTimer }) is the Harness clock: task times, sleeps and the timers of its tasks, such as a guest review's expiry, run on it; attachApprovals takes the same now and setTimer. Tests pass a fake one and move it forward.
  • openOpfsSqliteStorage({ directory?, file? }) keeps the session in wasm SQLite (@sqlite.org/sqlite-wasm) in an opfs-sahpool pool, by default /.slicc/agent/ in OPFS. It needs a dedicated worker. Only one worker holds a pool at a time: the opener takes a Web Lock per directory and waits until every file of the pool can be opened before sqlite-wasm installs it, because a failed install deletes the pool's directory. openMemorySqliteStorage() keeps the same database in memory. Document bases. Every session and conversation document the agent defines stores a complete base after 64 deltas (boundedDeltas in src/checkpoints.ts, the policy pi durable's codemode journal uses), so reading one replays at most 64 changes: with deltas only, a document changed 5,000 times took about 26 ms to read after a reopen, against under 1 ms. Task documents, which end with their task, keep deltas only. Profile marker: every open, under the pool's lock and before pi durable opens (and migrates) the database, writes <directory>-profile.json next to the pool, /.slicc/agent-profile.json by default: { "durableSchema": 2, "agent": "<version>" }, with pi durable's CURRENT_SQLITE_SCHEMA_VERSION and the agent's version (the agent option, or the version in the agent's package.json). A durableSchema already in the file is never lowered. bios reads it from OPFS before it starts an agent, and refuses one whose pi durable schema is lower (slicc-agent 6.x is 1): the schema version lives in a durable_schema table, not in the SQLite header, so the database itself can't be checked without opening it. On pi durable 1.4, opening a database written by an earlier agent migrates it to durable's SQLite schema 2, once and in place; an agent on pi durable 1.3 or earlier (slicc-agent 6.19.13 and earlier) can't open it afterwards, so the agent can't be rolled back past this version. Requests through the Harness are attributed to SLICC (identity: { attribution: { name: 'SLICC', url: 'https://www.sliccy.com' } }), which pi-ai sends as OpenRouter's HTTP-Referer and X-OpenRouter-Title, NVIDIA's X-BILLING-INVOKE-ORIGIN and OpenCode's x-opencode-client; OpenRouter's preflight allows them from https://seven.sliccy.ai, and NVIDIA and OpenCode answer no browser preflight at all, so they go through a CORS-free transport either way. pi durable's crash-loop guard is on with its default: a task cut off 5 times in a row without committing a new state (a crash, a reload or restart(), which end the worker without closing the session) ends instead of being parked. A built-in generation, tool or compaction reports task_interrupted; any other task reports task_faulted with reason restart_limit. The monitor alerts that as an error and offers no command to resume it. agent retry <task id> says to submit the work again. Only the user may ask; an agent's agent retry is refused.
  • hostAgent(agent) serves the agent from one Chord host named agent (serveAgent in src/host.ts). Its remote contracts, in src/services.ts, are slicc.agent.control (send with whenBusy steer, followUp or reject, wait, withdraw, abort, compact) and slicc.agent.transcript (the conversation's durable view as replicated state, plus deliveries: how each user entry was delivered, run, steer or follow-up, derived by the host from where each send it accepted was placed: after a tool result it steered, queued first it's a follow-up or a late steer per its whenBusy, placed at once it started a run), slicc.agent.settings, slicc.agent.remote (remote control), slicc.agent.pages (Panels) and slicc.agent.guest (Guest seats). serveConnections(self, host) hands it every MessagePort the page sends, and each port becomes one Chord link whose principal is a seat: owner (a page of the owner, with its own Panels), control (a follower with Control), view (a follower without) or guest (a guest seat, with its id, label and epoch), plus the link's number, so every link gets its own objects. The principal is typed (AgentPrincipal in src/serving.ts); the host's own code acts as { seat: 'host' }, which no link gets. Each factory picks a variant per seat. Every contract places its objects on the host agent (hostNamed), so no caller names a host. The contracts take their methods' arguments as one JSON array, and the client in src/serving.ts turns them back into the AgentControl, AgentSettings, AgentRemote and AgentPages interfaces and their replicated states, so callers don't see Chord.
  • @ai-ecoverse/slicc-agent/page has what the page needs (startAgent, connectAgent) without the agent's runtime: no pi-durable, pi-ai, providers or SQLite, which load only in the worker. @ai-ecoverse/slicc-agent/spectrum is just as light, and a test keeps both that way.
  • startAgent({ worker }) takes the Web Lock slicc-agent, so one tab at a time owns the agent worker, and waits while another tab holds it. connect() returns an AgentConnection (control, transcript, prompt(), and webhook(name, { id?, headers?, body }), which hands a webhook delivery, such as one from the tray hub, to the licks without a chord Context), restart() replaces the worker (durable resumes from SQLite), and release() stops it and lets the next tab take over. port() returns a raw MessagePort into the worker instead, for another tab: hand it over (through a SharedWorker, say), and that tab calls connectAgent(port). A caller that already holds the lock passes held: true. A connection's closed resolves when its port ends (with the error, if any), and from then on its states keep their last values.
  • Followers over a link (PR 18): port({ readOnly: true }) and connect({ readOnly: true }) give a view-only connection, and port({ pages: false }) a full one that never takes panel requests (see Panels).
    • What a view-only connection can do: it sees the transcript, the agents, settings state and remote-control watch, and may call wait and changesOpen. It's still an owner device, so it has the owner-device methods (OWNER_DEVICE_METHODS in src/services.ts) with the owner's powers: decide, guestOpen, guestRevoke, guestAlwaysForget and approvalPolicySet. Control gates driving, not approvals.
    • What it can't: every other AgentControl method, settings action and remote-control prompt or abort is refused with EACCES ("this follower can watch, but not send, steer or abort"). Send and compact answer accepted: false with that code, and addServer and removeServer answer not-owner. The view seat's variants don't have those methods at all (view for control and settings, watch for remote control, none for Panels); the follower's client answers the missing ones with the refusals (src/readonly.ts), so the worker never runs them. A watch stream is readable only over the link that opened it.
    • Carrying the connection over a byte stream: linkEndpoint({ write, close }) is a PortEndpoint that connectAgent accepts. Feed it the stream's bytes with receive(bytes) and its end with end(error?). On the owner's side, bridgePort(port, { write, close }) does the same for the MessagePort from port().
    • The bytes: the owner first posts a hello, its server id, then each Chord frame travels as its JSON text in UTF-8 (PortLink in src/wire.ts), and { close: true } ends the port. The owner holds its own Chord frames until the client's Chord hello arrives, so it knows which Chord it talks to before it says anything. PortLink.send returns each frame's size, so Chord's flow control applies: once 256 KiB are unacknowledged, state waits at the owner, coalesced per member, instead of piling up in front of a slow WebRTC follower, while calls and results still pass. On a byte stream each message becomes a frame: a u32 big-endian length, then a tag (0 data, 1 hello, 2 close), then the payload. A frame is at most 16 MiB, and a malformed one ends the link.
    • Another agent version: connectAgent and connectGuest close the link and reject once with code: 'EVERSION' and the sentence "That SLICC runs another version of the agent, so this tab can't follow it. Update both SLICCs, then reload this page." It is final: a follower should show the sentence and not retry. That happens when:
      • Chord refuses the handshake because the owner's contracts don't match (upgrade_required or contract_mismatch; Chord's error is the cause);
      • Chord reports another link protocol (onProtocolMismatch, from Chord 1.3 on);
      • the owner runs agent 5.x, whose Chord 1.2 hello carries no protocol version: the client leaves at once.
    • A 5.x follower or guest of a 6.x owner: the owner sees its Chord 1.2 hello, never hands that link to Chord, and answers each of its holds with Chord 1.2's gone and upgrade_required, which 5.5.2 and later already turn into EVERSION. Agent 6.0 runs Chord 1.3, whose link protocol (4) refuses 1.2 peers, so 5.x and 6.x don't follow each other.

Bash and files on slicc-kernel

The page owns the one kernel (plan decision D3), and the agent worker attaches to it as a second client (slicc-kernel#66), so a terminal's ps and kill reach whatever the agent started.

const owner = await startAgent({ worker, kernel: { connect: () => kernel.connect() } });
import { attachKernel } from '@ai-ecoverse/slicc-kernel';
import { CodingTools } from '@earendil-works/pi-durable/tools';
import { kernelEnvironment, kernelPort } from '@ai-ecoverse/slicc-agent';

const client = await attachKernel(await kernelPort(self));
registry.install(CodingTools);
const agent = await openAgent({ models, model, storage, registry, env: kernelEnvironment(client) });

SliccKernelEnv implements pi durable's ExecutionEnv on the kernel client and passes pi's environment conformance suite:

  • string commands run as bash -c. All of one agent's commands share one process group: the first leads it and later ones join it with spawn({ pgid }) (slicc-kernel ≥ 1.19.0, client protocol 1.3), so ps -o pgid and kill -- -<pgid> cover a whole scoop from any terminal.
    • Fallbacks: if the group has no live member left (ESRCH) or belongs to another session (EPERM), the command leads a new group that later commands join. On an older kernel (ENOSYS), every command leads its own group, as before.
    • Timeouts and aborts kill only that command: its descendants are read from ps() first, then all are killed together with it, so a background job the agent started earlier keeps running. A process forked in the instant between that snapshot and the kill can slip through; it ends when the agent's groups are stopped;
  • output past the spill limits goes to /tmp/slicc-agent-output-*.log;
  • files and directories go through the kernel's OPFS view and metadata sidecar;
  • watch() uses the kernel client's own fs.watch (slicc-kernel protocol 1.1), so a change made through the kernel by any process or client is reported in the same task. A watched path whose parent is renamed or replaced counts as changed, and a watched symbolic link also reports changes to its target. The kernel's watches are on names, so a target that doesn't exist yet is watched natively like any other and its creation is reported; the rest of its group isn't affected. A target under a symlinked directory is also watched at its real path (for a missing target, the real path of its nearest existing directory with the rest after it), and that is resolved again when the target or a directory above it changes. A missing target the client may not reach (EACCES under users) is left to the rescan, which retries its watch once it shows up. Writers that bypass the kernel, such as spectrum's Files panel writing OPFS directly, are caught by a rescan: every 2 s for a single file or directory (watch: { rescanMs }), and every 30 s for a recursive target (watch: { deepRescanMs }), at least 50 times the last rescan's duration later. On a kernel whose client watches also hear other kernels on the root and postFsNotice (it has /proc/sys/kernel/slicc_meta_transactions, read once per client with cat, and that shows shared metadata), recursive targets aren't rescanned at all unless deepRescanMs is set, so a writer outside the kernel has to post postFsNotice to be heard there. Only a kernel without fs.watch (ENOSYS) makes the whole watch poll; watch: { mode: 'polling' } forces that. Polling starts the next scan intervalMs after the last one finished (by default 2 s when a target is recursive, 500 ms otherwise), and at least 10 times the last scan's duration later. Scans never overlap or queue up: all watches of the worker take turns, one scan at a time, and each watch has at most one waiting.

A conversation without a cwd works in /home.

Kernel users. A KernelSource may also have users (add, remove, list, as kernel.users), and its connect takes { user, agentId } for a client bound to that user. The page builds the kernel's connect({ user, tag }) from them, and so decides what the tag looks like. It may also have groups (list, addMember, removeMember, as kernel.groups). With users, the worker gets a second port next to the kernel's, for these calls (kernelPorts(self) resolves { kernel, admin? }), and the AgentHost has users, a pool of one bound client per agent (userClients). Without users (or with runAgentWorker(self, { users: false })), nothing changes, and everything below about users doesn't apply:

const owner = await startAgent({
  worker,
  kernel: {
    connect: (options) =>
      options?.user
        ? kernel.connect({ user: options.user, tag: JSON.stringify({ kind: 'agent', agentId: options.agentId }) })
        : kernel.connect(),
    users: kernel.users,
  },
});

At boot, the worker sets /.slicc, where the session database lives in OPFS, to 0700, so that only root can read it through the kernel.

With users, each cone and each scoop is a kernel user, and works as that user:

  • Users and groups:

    • a cone cone or cone-<n> is the user of the same name, in sudo, with home /home/<cone> (0710) and umask 077;
    • a scoop is scoop-<handle>, a member of its cone's group, with home /home/scoop-<handle> (2750, the cone's group) and umask 027.
    • So a cone reads its scoops' work, and a scoop reads only what its cone shares with chmod g+r (and g+x on the folders on the way).
    • The worker adds every user at boot, and on every change to slicc.agents. Stopping, dropping or removing a scoop removes its user and its home; scoops go before their cone.
  • Clients: a conversation's bash, file tools and codemode, and its background tasks, use its agent's bound client. Its cwd defaults to its home, and the kernel sets HOME, USER and PATH. The client carries the page's tag for its agentId, so a sudo prompt names the agent.

  • The agent command: each user has its own request spool, /var/lib/slicc/agent/requests/<user>/ (0700). The spool's owner is the caller, so a SLICC_AGENT that names someone else is refused. --file and --system-prompt-file are read as the caller.

  • Sharing in a sync run: when the caller's cwd, or a --read-only path, is inside the caller's home, the run first shares it with the scoop: chmod -R g+rX on it and g+x on the folders above it. The v6 form's working folder gets g+rwX, plus g+s on its folders. The modes stay afterwards.

  • Where things are:

    | | | |---|---| | settings, MCP servers, AGENTS.md, prompts, roles | /etc/slicc/agent/ (root); mcp.json is the worker's alone: Settings › MCP servers writes it 0600 root:root, a file made by hand keeps its mode until that next write, and an agent needs sudo to change it | | skills for every agent | /usr/share/skills/ (root, 0755), as without users | | an agent's own skills | ~/.pi/agent/skills and ~/.agents/skills in its home; only that agent sees them | | memory | /root/.pi/agent/memory/ and /root/.pi/agent/agent-memory/ (root; agents use memory_write and memory) | | licks | each cone's ~/.slicc, whose cone target is that cone; root's /root/.slicc, whose cone is the active cone. A cone's watches run as that cone, and not at all while its environment can't be resolved. Schedules are each user's kernel crontab: a cone's, a scoop's own, and root's | | sprinkles | each cone's ~/sprinkles, every cone's in the rail, with the id <cone>/<name> (the cone's id, such as cone-2) and the cone as agentId; panel open sprinkle:<cone>/<name> opens one, and spectrum scopes the rest by cone. welcome and suggestions keep their plain ids. spectrum ≥ 1.56.0 groups the rail by cone, with the selected cone first; an older spectrum lists them ungrouped. A sprinkle's files are read as its owner, inside the owner's home | | the gelatiere | its procedure in the first cone's home, written as that cone (gelatiere init is refused when that cone's environment can't be resolved), and its nightly line in the gelatiere scoop's own crontab; gelatiere install, suggest and catalog run as the caller, and are refused when the caller's environment can't be resolved | | scoop reports | the cone's ~/.slicc/reports/<handle>/ | | sync transcripts | /tmp/agent-sessions/<user>/ (0700), or ~/sessions/ with --persist-session | | the suggestions store and catalog cache, the welcome flag and profile | /var/lib/slicc/agent/shared/ (root, 0700). The built-in welcome and suggestions sprinkles read it as /shared/…, through the worker and only inside that folder; a user's sprinkle reads /shared/… as its owner's home |

  • No migration: there's nothing for old installs. Every install starts with this layout.

  • No scoop guard: only with users: 'enforce' do the kernel's permissions decide what a scoop's tools and bash may read and change. With users: 'off' or 'report' (which only logs what permissions would refuse) nothing confines them, and the scoop is told so.

The worker seven starts

@ai-ecoverse/slicc-agent/agent-worker is a complete agent worker. It waits for the page kernel's port, then:

  • attaches to the kernel and routes every cross-origin fetch through the kernel's transport, whose router picks a linked slicc exit, the extension or the page's own fetch, and every <port>.kernel.localhost request to the kernel's loopback (loopbackFetch). Same-origin and credentialed (credentials: 'include') requests, and all of them when the kernel has no transport (traits.unavailable), use the worker's own fetch, since only it carries the browser's cookies. The system prompt names the route from the traits at attach: traits.exit ({ kind: 'local' | 'remote' | 'tailnet', caps }, from slicc-bios while it has a default exit) says a linked slicc exit, one on another device or the tailnet's exit node, and any other kind a linked exit, and without it crossOrigin: 'cors' says the page's own fetch;
  • opens the vault-backed credentials (see Credentials), the AWS Bedrock provider and the session in OPFS SQLite;
  • installs durable's coding tools and SLICC's system prompt, and serves the agent.

The system prompt has a system section generated at startup from the running worker (sliccPrompt(facts), systemSection). It covers:

  • the slicc-agent version, read from its own package.json, and that bash runs on the page's slicc-kernel in the browser;
  • the filesystem layout, with /os and /opt as the system;
  • the commands on PATH, listed by a kernel bash;
  • installing tools with pnpm add -g, and that there is no ipk;
  • the active transport, from its traits: the page's fetch, a local proxy, or another relay that fetches without CORS (traits can't tell which);
  • that localhost is the sandbox's own loopback;
  • what isn't there yet: node or python3 when not on PATH, a browser or CDP tool, and GitHub credentials.
const owner = await startAgent({
  worker: () => new Worker(new URL('@ai-ecoverse/slicc-agent/agent-worker', import.meta.url), { type: 'module' }),
  kernel: { connect: () => kernel.connect() },
});

runAgentWorker(self, options) is the same with its parts replaceable (model, providers, kernel attach, the account store, storage).

Providers. sliccProviders() registers Bedrock, Adobe and every pi-ai provider that takes a plain API key: Anthropic, OpenAI, Mistral, Groq, DeepSeek, Together, Hugging Face, Xiaomi and its three token plans, Cerebras, OpenRouter, Fireworks, Baseten, Moonshot AI and its China platform, xAI, Meta, Vercel AI Gateway, MiniMax and MiniMax CN, Z.AI and Z.AI Coding CN, NVIDIA, Kimi For Coding, Ant Ling, OpenCode Zen and Go, and the three Qwen token plans (KEY_PROVIDERS in src/providers.ts, each with its id, pi-ai factory, pi-ai env name, a description, a key URL and whether it answers browsers). Three more take a key and non-secret fields (auth: 'fields'), each checked in connect and mapped to pi-ai's env: Azure (endpoint, required: a resource name becomes AZURE_OPENAI_RESOURCE_NAME, an https: URL without a port or user name AZURE_OPENAI_BASE_URL; deployments, optional model=deployment pairs, AZURE_OPENAI_DEPLOYMENT_NAME_MAP), Cloudflare Workers AI (account, 32 hex characters, CLOUDFLARE_ACCOUNT_ID) and Cloudflare AI Gateway (account and gateway, CLOUDFLARE_GATEWAY_ID). Bedrock's region is on the same mechanism. A bad value rejects connect with a sentence, a required field left out keeps its stored value or rejects with “{label} can’t be empty.”, and a stored value that no longer passes is left out of the env. A fields provider whose required fields are missing or no longer pass shows as not connected, gives pi-ai no credential and offers no models, even with its key already in the vault (spectrum puts the key before connect checks the fields). Bedrock's region is any AWS region name (eusc-de-east-1 and us-gov-west-1 included), and stored values are trimmed. OpenCode Zen and OpenCode Go need seven's vault 1.12.2 or later, whose built-in table has their /inference endpoints; their descriptions say so. Azure's endpoint field says customBase: true: when its value is a full URL, spectrum registers it with the vault (the page's endpoints) before connect, since the agent never registers model endpoints for pi-ai providers; a resource name needs nothing, as *.openai.azure.com is built into the vault. The worker routes requests to an Azure account's base URL as Azure's. Google and Google Vertex wait for slicc-bios to stop removing @google/genai; the sign-ins come later. A provider's preferred model is pi-coding-agent 1.4.0's defaultModelPerProvider (Bedrock keeps us.anthropic.claude-sonnet-5-5), and the first model when that one is missing. Providers that don't answer a browser from https://seven.sliccy.ai with the headers pi-ai and the AWS SDK send say needs: 'cors-free-transport'; the direct ones are Bedrock, Adobe, Anthropic, OpenAI, Azure, Mistral, Groq, DeepSeek, Together, Hugging Face, Baseten, both Moonshot AI platforms, xAI, Meta and the four Xiaomi providers. Z.AI Coding CN stays cors-free: the nightly check found it answering browsers on one day and not two days later. Chrome accepts Authorization under a bare Access-Control-Allow-Headers: * (as xAI, Meta and Adobe's proxy answer), checked in Chrome 156. The worker's transport fetch drops the Stainless SDKs' x-stainless-* telemetry headers from every request it sends, which is what Baseten's and Moonshot AI's preflights refuse.

Reachability, by hand. npm run reach (scripts/reach.mjs, through verdict.mjs) checks every listing from https://seven.sliccy.ai. It captures the request pi-ai builds for each of the provider's APIs (with a dummy key and dummy fields, and the fetch faked; Bedrock's from the AWS SDK's own request, caught at its Node HTTP handler, which carries the same headers as in a browser: amz-sdk-invocation-id, amz-sdk-request, authorization, content-type, x-amz-user-agent). Then it sends that request's preflight and, when the preflight passes, the request itself with dummy credentials, whose answer must carry Access-Control-Allow-Origin too. Besides each chat API it probes what the provider's models need beyond a turn: the requests its model discovery sends under its base URL (Adobe's /v1/config), one classifier call per classifier API (Adobe's /v1/ai/run, OpenAI's /v1/decisions, OpenRouter's and Cloudflare's), and Adobe's /v1/usage. It prints reach: <id> direct|cors-free|unknown|unprobed per provider (unknown when the network gave no answer; unprobed when it captured no request at all, which counts as a disagreement, so a provider can't silently drop out of the check) and one reach: PASS line. A provider that stays unknown three runs in a row counts as a disagreement too, when REACH_HISTORY names a file that keeps the streaks between runs, and the line says how many runs in a row; REACH_REPORT names a file for the JSON report. A direct provider that passes only because Chrome lets Authorization through under a bare Access-Control-Allow-Headers: * reads direct (wildcard auth) (today Adobe, xAI and Meta); the PASS line and the report list them, without failing, so we know at once which break if Chrome ever enforces the Fetch spec. No workflow runs it: it calls the real providers' endpoints, so it is a check to run before changing a listing, and no CI job or integration test reaches a real provider (test/integration/reach.test.mjs fakes the network). A run without the PASS line fails.

The first provider is AWS Bedrock with a Bedrock API key as the bearer token; the default model is us.anthropic.claude-sonnet-5-5. Requests go to us-west-2 unless the account's region field names another one (AWS_REGION). The account publishes that field as fields: [{ name: 'region', label: 'Region', help, options, value }], and connect takes it. Bedrock's runtime endpoints answer browsers (Access-Control-Allow-Origin: *, allowing the AWS SDK's headers), so it needs no CORS-free transport.

Adobe. adobeProvider() reaches Adobe's LLM proxy (ADOBE_PROXY) with the user's IMS token as a bearer. Its models come from the proxy's /v1/config once a token is stored: Anthropic-style models go through pi-ai's anthropic-messages API at the proxy root, api: 'openai' models through openai-completions under /v1, and hidden ones are skipped. Every request to the proxy (turns on either API, Clef, /v1/config and /v1/usage) carries an X-Session-Id that is fixed for the worker and an X-Slicc-Version (slicc-agent's version, adobeProvider({ session, version })); the proxy refuses turns without them. They're added where the request is built, over the caller's and the model's headers, because pi-ai 1.0.4 keeps a provider's headers but never sends them. The account is a sign-in (auth: 'oauth'): AgentSettings.signIn('adobe') returns the IMS clientId, scopes and imsEnvironment from /v1/config, fetched in the worker through the transport. The page runs the IMS popup and seals the token in the vault itself (bios: seal { kind: 'model', provider: 'adobe', name: 'ADOBE_IMS_TOKEN', source: 'oauth:adobe', expiresAt }), so createAgentModel(connection, { login }) takes a login(providerId, signIn, { signal }) that resolves { sealed: true }; the adapter then calls connect('adobe') with no secret. A login that still returns a token is refused with "Adobe sign-in needs seven's vault: update seven", and the token never reaches the worker. IMS gives no refresh token, so an expired record shows the account as expired, and Sign in (or Reconnect) runs login again. The worker derives that from each vault record (records(), any account kind, MCP rows included): a record that isn't ready or refreshing (such as inert), or whose expiresAt (epoch milliseconds, the vault's EpochMs) has passed, reads expired, and a timer flips it when the clock reaches expiresAt, so an open page needn't wait for the vault to change. A model request that still gets a 401 after the vault's one refresh marks the record it was sent with expired too, until that record changes (a new sign-in gives it a new expiresAt) or the account is connected again (connect), not when an unrelated record changes, so the error card's Log in lands on an account that offers Sign in. While it runs, the account shows as signing-in (spectrum ≥ 1.34.0), and SettingsPort.cancel(id) aborts the signal; a cancelled sign-in never connects, even if the login resolves late. The adapter calls login before anything else in the click's task, so the page can open its popup while it still has the user's gesture, and signIn() then fetches the IMS options lazily. The weekly budget from /v1/usage is in the settings state (refreshed on connect and every five minutes); see Budgets. The proxy answers browsers from seven.sliccy.ai, so Adobe needs no CORS-free transport. An expired session's error card (a 401, or a refusal whose type or message says the token is invalid or expired) offers Log in, reads “Log in again to continue.” (the raw error goes to the console once, already scrubbed, never onto the card) and names its account (account: 'adobe', spectrum ≥ 1.88.0), so Log in calls settings.connect('adobe') inside the click and spectrum's card follows the sign-in (its own copy while signing in, then “Signed in to Adobe again.”, from the account's provider), or shows the failure as a sentence (a login that rejects with “the sign-in timed out” reads “The sign-in timed out.”; a cancel stays an AbortError, which shows nothing). A rejected or missing key's card, and the “isn't connected” error, name their account too, so spectrum opens Settings at that row; any other refusal, such as a 403 missing_required_header, shows the proxy's message, with the HTTP status and error type below it (403 missing_required_header) and Retry.

Classifiers. The Adobe provider also registers Cloudflare's Clef classifiers as adobe/clef and adobe/clef-flash (adobeClassifiers()): type: 'classifier' models on pi-ai's cloudflare-workers-ai-system-one API with baseUrl: <proxy>/v1/ai, so models.classify(model, { state, questions }) posts { model: '@cf/cloudflare/clef', input } to the proxy's /v1/ai/run with the IMS token as the bearer. The provider maps its short ids to the Workers AI names, and pi parses the answers and prices the usage ($0.24 per million input tokens for Clef, $0.09 for Clef Flash, nothing for output), which counts against the same weekly budget. They are classifiers only: models.getModels(), the settings models and the picker list chat models, and models.getAvailableOfType('classifier', 'adobe') returns them only while an Adobe token is stored.

Model credentials. They live in the vault, never in the worker (see Credentials).

Settings. The slicc.agent.settings service shows the models and accounts and takes connect(providerId, secret, fields), disconnect(providerId) and remove(providerId). An account's id is its lookup key: the pi-ai provider id for model accounts (the vault record's provider), and mcp:<server> for MCP servers (the record's server is the mcp.json key); provider is only the display name. Accounts whose key the agent reads from the vault carry store: 'vault': spectrum (SPEC-S4) puts the key into the vault itself, then calls connect(id) with no secret, and the agent rereads records(). fields holds an account's non-secret settings (Account.fields, unknown names ignored), kept in /.slicc/accounts.json. The model lists a provider discovers (Adobe's /v1/config) are kept in /.slicc/models.json, pi-ai's ModelsStore. The worker restores them offline before the harness opens, so a run that a reload interrupted resumes on its model instead of ending as no_model before discovery runs again (#483). Removing the key is spectrum's vault.remove(name, kind), since the agent port can't remove records.

Blocked providers. needs: 'cors-free-transport' is the static fact; blocked: 'cors-free-transport', on both ProviderOption and the account, says the current transport can't reach that provider right now: the worker's kernel transport is unavailable, or it is the page's own fetch without a linked exit (transportReach(client) in src/net.ts, from the transport's traits). The worker follows client.onTraits, so a provider unblocks as soon as an extension or a linked slicc gives a CORS-free route, and blocks again when it goes, with no reload. A blocked provider is still offered and can be added; a connected one keeps its key and its row, but its models are left out of models and labels, so the picker offers none until the route is back.

Budgets. The settings state's budgets lists each subscription's weekly window as { provider, percent, window: 'weekly', resets }: Adobe's from /v1/usage, then SuperGrok's, and budget is the first of them (Adobe's when both are there). SuperGrok's (xaiUsage in src/supergrok.ts, as in SLICC 6) is GET https://cli-chat-proxy.grok.com/v1/billing?format=credits with the xAI record's placeholder as the bearer. It's read only for an xAI sign-in (a record with an expiresAt, not expired; a pasted key has none) and only while the cors-free transport is up (reach.free()), since that host doesn't answer browsers. It's read at most once per five-minute poll for the same record, with an 8 s timeout. The route is undocumented, so the answer is read defensively: a body over 64 KiB, malformed JSON, or anything but a USAGE_PERIOD_TYPE_WEEKLY currentPeriod with a finite creditUsagePercent and a valid end gives no budget, and the percent is clamped to 0–100 and rounded to one decimal. The vault fills the token only where xAI's endpoints allow: until its built-in table has the billing route, it refuses the request before anything is sent, and xAI shows no budget. The tray and monitor adapters show the budget of the selected model's provider (the active agent's model), or the first budget when that provider has none, and pass its provider (spectrum labels adobe “Adobe” and xai “SuperGrok”). The Monitor's budget vital carries provider and window too, which spectrum's heading is built from, so its own label stays “Budget (weekly)”; its alert names the provider: “SuperGrok budget 96% used”. test/integration/budget.test.mjs checks the switch, the refused endpoint and an oversized answer.

Held sign-in windows (src/spectrum/held.ts, slicc-bios#772). A sign-in popup opens in the click on about:blank#slicc-sign-in-<nonce> (a random UUID), and the page asks seven's owner to hold it with { type: 'hold', marker: <nonce> } on the slicc-held BroadcastChannel. The owner holds the target with that exact URL, so no CDP client can list or drive it, and answers { type, marker }: held once it's held on every connected browser host, none when no host is connected (nothing can reach the popup), and unheld when a host is connected but the target wasn't found. The popup goes to the IdP or device page only on held or none. On unheld or after 6 s without an answer, the popup closes without navigating and the sign-in fails with “SLICC couldn't protect the sign-in window. Try again.”; on Cancel it never navigates either.

Subscription sign-ins. xAI, Kimi For Coding and ChatGPT (openai-codex, shown as ChatGPT) sign in with pi-ai's own device-code flows (DEVICE_SIGN_INS in src/sign-ins.ts); xAI and Kimi keep API-key entry too, so their accounts are auth: 'oauth' with secret: 'api-key' (spectrum's “Use an API key instead”) and a keyUrl. Spectrum's Sign in calls connect(id, undefined, undefined, { method: 'sign-in' }) in the click, and a pasted key puts first and calls connect(id, undefined, fields, { method: 'key' }) (spectrum's ConnectOptions); without the fourth argument an oauth account signs in. The adapter opens a held popup (slicc-sign-in, see Held sign-in windows) before anything else in the click's task, then asks the worker's signIn(id), which adds the account, runs the provider's auth.oauth.login and answers { url, code } once the provider gives its device code (an https: page only). While it waits the account is signing-in with signIn: { url, code? }, kept in the page and never in the replicated settings state; the prompt appears and the popup goes to url only once the popup is held, and spectrum shows code with Copy and a link to url when the browser blocked the popup. The adapter's connect call then waits in the worker for the token (a sign-in no connect claims within a minute is dropped) and hands the vault grant { provider, name, response, settings? }, name being the account's vaultName and response the token response built from pi-ai's credential, as bytes zeroed after the call; the token is never stored, logged, licked or put in the state, and pi-ai's own error texts, which can quote a token response, never leave the worker. A refusal reads “The sign-in was refused.”, a timeout, network failure or malformed answer “The sign-in didn't finish. Try again.”, the vault's EEXIST “This name holds another provider's key.”, and Cancel aborts at once with an AbortError. Signing in again is a rotation that keeps the placeholder, and over a pasted key the vault keeps that key restorable. The vault refreshes the token through egress; after invalid_grant its record is expired and the account offers Sign in again. ChatGPT's API reads the account id from the access token's JWT, which a placeholder isn't: the grant carries settings: { CHATGPT_ACCOUNT_ID } (every sign-in), the credential's env gets it from the record, and the worker hands pi-ai a carrier key slicc.<claims>.<placeholder> that the worker's fetch turns back into Bearer <placeholder> before the vault fills it, so pi-ai sends chatgpt-account-id itself; its turns use SSE, not WebSockets. Kimi's auth host answers browsers but its API needs the CORS-free transport; xAI's API answers browsers but its device endpoints don't, so an xAI sign-in needs the CORS-free transport while its key doesn't; ChatGPT's auth host answers browsers and its API doesn't. The flows load lazily from pi-ai: xAI's and Kimi's modules import nothing from Node, and openai-codex's imports node:http for its browser callback, which the device flow never runs, so slicc-bios's node:* stubs let it load.

Adding providers. The settings state's providers lists every provider seven can add (ProviderOption: id, name, description, auth, keyUrl, needs, fields and vaultName, sorted by name), and the spectrum adapter serves it as providers() with a providers event when it changes. accounts then lists only the added ones: Bedrock and Adobe start as added, connect adds a provider, and one with a vault record or stored fields counts as added too. The added list is providers in /.slicc/accounts.json. vaultName is the record name spectrum puts the key under: pi-ai's env name, or <ID>_API_KEY for providers that share one (Moonshot AI, OpenCode and the Qwen token plans), since a put under another provider's name would rebind that record; Bedrock's is AWS_BEARER_TOKEN_BEDROCK and Adobe's ADOBE_IMS_TOKEN. The agent finds a provider's record by its provider, never by name. Each provider has at most one secret, its key; fields are the non-secret inputs (name, label, required, placeholder, help, customBase, vaultSetting, options and value). vaultSetting names the vault record setting a field fills (slicc-vault's tenant-bound wildcards, V3), so the vault can bind a provider's wildcard endpoints to the user's own tenant: Cloudflare's account is CLOUDFLARE_ACCOUNT_ID and gateway CLOUDFLARE_GATEWAY_ID, and Azure's endpoint is AZURE_OPENAI_RESOURCE_NAME (spectrum applies it only to a bare resource name; an https: value stays a customBase). On an added account each field's value is its stored value, trimmed, so Settings can show it and knows a custom base URL to unregister after a reload; a field with a default carries that instead (Bedrock's us-west-2). Bedrock's region has options, the 21 regions where AWS supports Bedrock API keys (the bearer token seven uses; AWS's “Supported Regions … for Amazon Bedrock API keys”), labelled with AWS's names (Europe (Frankfurt), eu-central-1); a stored region outside the list comes first, under its own name, so a select never shows a region the account doesn't use. disconnect(id) keeps the row and its fields, so connecting again needs only the key, and remove(id) drops the row and its fields; for both, spectrum removes the vault record first. On an MCP row, remove disconnects it. connect with a secret is refused for every model account ("API keys are kept in seven's vault: update seven"); the only secret it still takes is an MCP sign-in's callback answer.

In slicc-spectrum

@ai-ecoverse/slicc-spectrum is an optional peer dependency, ^1.112.0 || ^2.0.0. 1.112.0 makes an approval verdict's decision optional, which the agent no longer sends. 1.102.0 types a guest tool approval's args and argsTruncated, and 1.103.0 shows a device sign-in's signIn code and link in Settings. 1.107.0 heads the budget with its provider: the tray's budget.provider and the Monitor vital's provider and window (older releases ignore them). 1.94.0 is the first release with the provider picker (providers(), remove, vaultName, customBase, a field's options and value); 1.94.1 shows a field with options as an sp-picker on its value; 1.95.0 shows a blocked provider in the picker as unpickable with “Needs the SLICC extension or CLI”; 1.96.0 registers an MCP token's server URL (Account.url) with the vault itself, after a “Connect to {url}?” confirm, and offers Reconnect when the URL moves; and 1.98.0 saves a field's value as its vaultSetting on the vault record (VaultPort.settings, V3). test/integration/settings-ui.test.mjs checks each in a real <slicc-settings>: Bedrock's region picker on the stored region, a blocked provider that can't be picked until a CORS-free route appears, the moved note once mcp.json points a connected server elsewhere, and that adding Cloudflare Workers AI or Azure (by resource name) puts the key with its tenant fields as the record's vault settings (CLOUDFLARE_ACCOUNT_ID, AZURE_OPENAI_RESOURCE_NAME).

@ai-ecoverse/slicc-agent/spectrum turns an AgentConnection into slicc-spectrum's AgentPort, so <slicc-app> shows the real agent:

import { createAgentModel } from '@ai-ecoverse/slicc-agent/spectrum';

const agent = await owner.connect();
app.model = { ...createKernelModel({ kernel, root }), ...createAgentModel(agent, { storage: localStorage }) };

createAgentModel() returns the agent, settings, tray, sprinkles, memory, changes and monitor ports, and page, the tab's end of the panel bridge, when it's given page: { tab, run } (see Panels). Settings keeps the UI preferences in storage; model and thinking belong to the conversation, and accounts and models come from the worker.

The adapter keeps the replicated transcript and maps it on every update. User, assistant and compaction entries become messages; tool results fill in the tool calls they answer; the generation in flight shows as a streaming message; retries and running compactions show as system messages. A failed generation ends in an error card whose action fits the error: credential errors (an invalid or missing key, 401/403, not authorized) open settings, a model the account can't use offers the model picker, and anything else offers Retry. A content-filter stop is different: every later request resends the content that tripped the filter, so Retry, and any new message, would be stopped again. Content-filter stops are Bedrock's content_filtered and guardrail_intervened, Anthropic's sensitive and refusals, and OpenAI's content_filter. Their card leads with "The model's content filter stopped this reply." and offers Drop the last turn (spectrum ≥ 1.12.0). AgentControl.rewind(messageId) finds the user turn before that message and forks the cone's conversation at the entry before it (Conversation.fork, so pi.agent comes back as it was there). It points the cone at the fork and returns the turn's prompt, which spectrum puts back into the composer. Forking the very first turn creates a fresh conversation with the same model and thinking level. The filtered branch stays in storage, off the active path. The fork starts with a slicc.rewound entry that shows as a "Rewound 1 turn" notice, and clicking the card of an earlier filtered turn rewinds that turn.

Older messages. An agent's view is durable's window, its newest 50 to 100 entries, approval verdicts and other entries the chat doesn't show included, so by itself it holds only the last few turns. The adapter keeps each agent's whole transcript in a pi-durable ConversationTranscript instead: every view merges into it, so messages the window drops stay, and a window that jumps ahead (a reconnect, a burst) leaves a gap the adapter fills at once, from its newest side, up to 40 pages (4,000 entries) per gap. A bigger jump keeps only the newest entries, contiguous, and the rest comes back through older(), so a gap never downloads a whole conversation. older(agentId, before) loads earlier pages of 100 entries through AgentControl.entries until at least one message older than the message before appears, or the conversation starts, and resolves the messages before before (before the first message when before is unknown); messages(agentId) holds them from then on, and the change emits messages. An empty answer means the start is reached, and hasOlder(agentId) says whether there is more. Concurrent calls share one load, an agent's older answers never count as unread, a notice stays after the last committed message it followed (not after a reply still streaming, a queued input or a retry, whose ids change), and a new conversation (New chat, a rewind's fork) starts a new transcript. AgentControl.entries({ agentId, conversation, minEntryId?, maxEntryId?, order?, limit? }) answers { conversation, entries, more }: a page of the agent's fork-aware history (Conversation.entries()), at most 200 entries, slimmed like the views (large images become thumbnails; a page waits at most 2 s for its own thumbnails, then answers with a placeholder), and an empty page when conversation is no longer the agent's. Owner, control and view-only seats have it. A view-only seat's image answers the full image of an entry only while the entry is in a current view, or when that seat itself read it through entries and the entry is still in that agent's current conversation (checked again on each call, so a page read before a rewind or a new chat, or by another seat, grants nothing); guests see only their window, and so does an adapter on an older agent whose control service doesn't offer entries (connection.history is true only when it does; older() then answers [] and hasOlder() false).

Each cone's active conversation is in the session document slicc.agents (the root until the first rewind). The host's transcript follows it across forks. Rewinding is refused while the cone itself is running or has queued input, because a fork starts with an empty inbox, but never because its scoops are busy (see Cones and scoops). A background compaction still running on the old branch finishes there. A fork also starts with fresh pi.usage. Credential and model errors lead with a plain sentence ("Bedrock rejected the API key.", "This model isn't available with the current account.") and keep the provider's raw error as detail, which spectrum (≥ 1.11.0) shows underneath. busy() and queue() read durable's live and inbox documents. Steering is the default (#4): a send or a steer goes out with whenBusy: 'steer', so while a run is going it joins after the current tool round, and a queue send is a follow-up. User messages carry delivered from the host's deliveries; entries the host didn't record (from before a reload) count as steered when they follow a tool result. Cones and scoops are below; the freezer and questions come later. Licks show as spectrum's lick cards (below). slicc-spectrum is an optional peer dependency (^1.112.0 || ^2.0.0, the lowest whose monitor, tray, panel, guest seat, guest approval (with args and argsTruncated, and a verdict without decision) and profile import types match the agent's; npm run typecheck fails on anything older), used for types only, so package managers don't install it next to the agent.

Each agent in list() has a real contextFill and unread:

  • contextFill is the context the agent uses, as a share of its model's contextWindow from the settings' model list. The tokens are those of the newest assistant's usage after the active head, counted the way pi-ai's calculateContextTokens does (totalTokens, or input, output and both cache counts). Aborted and failed turns don't count. After a compaction, the kept turns' usage no longer counts. Until the next turn reports its usage, the fill is estimated the way pi does it, at four characters a token, over the summary and the kept entries (without the system prompt and tools). An unknown model also reads 0. The Monitor's "Fullest context" and its context alerts use the same functions (src/context.ts).
  • unread counts the assistant messages an agent finished while it wasn't the tab's active() agent. Answers already there when the page opened don't count, but an agent that appears later counts from its first answer. It's counted once the turn is done, and select(id) resets it to 0. Each change emits agents. It lives in the adapter, so each tab keeps its own, and it isn't stored.

Each tool call shows its input in the way that suits it:

  • bash: its command, with a timeout as the card's meta;
  • read: the path, with its line range as meta;
  • write: the path;
  • edit: the path, and its change as a diff (spectrum ≥ 1.13.0). While the edit runs, the diff comes from its oldText/newText pairs. Once done, it comes from the hunks of the unified patch that pi's edit tool returns in its result details, with context lines;
  • other tools: their arguments as pretty-printed JSON.

Attachments

A send's Outgoing.attachments reach the model. In the page, send() reads each attachment's url (a data: or blob: URL) into bytes before anything else, so a blob: URL spectrum revokes later has already been read, and passes them on SendRequest.attachments as { name, mimeType, kind, data?, text?, path? }, where data is base64 because chord carries JSON only. It skips attachments with an error and secrets. In the worker, control.send turns them into the user message:

  • an image (image/png, jpeg, gif or webp) becomes a pi-ai ImageContent part after a short <attachment kind="image" …> text part that names it;
  • a text file is inlined as <attachment kind="text" name="…" type="…"> with its text, and with its path when it is an @-mention;
  • any other file is written with the conversation's own environment, so the user's own client in users mode, into uploads/ under the conversation's cwd (/home by default), and the text says Saved at <path>. A free name gets -1, -2 and so on;
  • for a model whose input lacks image, an image is saved the same way and the text adds "This model can't see images, so the image was saved as a file instead." (pi-ai alone would put (image omitted: model does not support images) in its place, and the image would be lost to the tools.)
  • an attachment with only a path is mentioned by its path.

Images are limited to 5 MiB, other files to 8 MiB, and one message's attachments to 10 MiB together, because one link frame holds 16 MiB (10 MiB is about 13.4 MiB of base64 in one send) and providers refuse bigger images anyway. The page first shrinks an image over the limit (to 1600 px, as image reads do). Anything still too big isn't sent, and a notice says why, for example "photo.png is larger than 5 MiB, so it can't be sent.". Text and inlined files go through the vault's scrubbing like any send; images can't be scrubbed as text. The replicated transcript and views don't carry the full images: the host swaps every image part over 48 KB, in entries and in the inbox, for a JPEG thumbnail of at most 256 px and 48 KB (made once per image in the worker with OffscreenCanvas) plus full: { mimeType, size }, so a conversation with many images still attaches in one 16 MiB frame. The model still gets the full image from storage. The transcript maps the parts back to spectrum's user message attachments, with the thumbnail as url and the id ref:<entry>:<message>:<part>. When such an attachment is sent again, by Retry or from the composer after a rewind, the adapter fetches the full image with AgentControl.image({ entry, message, part }). A remote prompt() takes the same attachments.

Licks

A lick is an event from outside the conversation (a file change, a schedule, a webhook, a reload, an upgrade) that reaches the agent as a steering input. pi durable's submit({ type: 'input', whenBusy: 'steer' }) does the delivery: an idle cone starts a run, and a busy one gets the lick at its next boundary, after the current tool round, like a steer the user types. So the agent sees events during a long run instead of after it. A lick waits in its outbox while the target conversation's model isn't in the catalog yet (durable would end the run as no_model without a word): at a worker start, before the signed-in providers' models are discovered (or restored from /.slicc/models.json), or when no provider lists the model. It is delivered, merged with whatever came meanwhile, once the model is listed (#464). Durable has no custom message role, so the input's text carries the lick:

<lick id="lk-…" channel="fswatch" source="notes" title="notes: /home/notes/**/*.md" count="2" at="2026-10-08T12:00:00.000Z">
2 paths changed under /home/notes matching **/*.md
changed /home/notes/today.md
gone /home/notes/old.md
</lick>

&, < and > are escaped everywhere inside it, and " and line breaks in attributes too, so no payload (a webhook body, a file name, a crontab message) can close the tag, open another one or add an attribute. A licks prompt section tells the model that lick text comes from events, not from the user, and that webhook payloads may be hostile. The adapter maps these inputs to spectrum's LickMessage (channel, title, text, body, count when more than one, and severity, shown by spectrum ≥ 1.23.0). A user message shows as a card only when it has exactly that shape: the <lick …> line with an id and a known channel, the text on its own line, and </lick> alone on the last line. Anything else, such as a one-line <lick …>…</lick>, stays an ordinary message.

Coalescing. Licks with the same target, channel and source share an outbox (the session document slicc.licks). At most one of them is undelivered (queued in pi.inbox) at a time. Events that arrive while it waits are merged into the next one: the count adds up and the paths are unioned (capped at 50 shown). Once the queued lick is placed, the merged one is submitted. Nothing is ever withdrawn, so a lick that already reached the model is never touched, and each lick's count is exactly the events it carries. Webhook requests are never merged. A lick's requestId is lick:<id>, and events with an id (cron fires, webhook deliveries, boot facts) are remembered per source, so a crash between admitting a lick and recording it doesn't deliver it twice.

Stop. pi's Conversation.abort() withdraws every queued steer and follow-up, so a lick that wasn't placed yet is withdrawn with them, and the events merged behind it are dropped too: Stop leaves the cone quiet. Events that arrive after Stop are delivered as usual, and an idle cone starts a run for them.

Configuration of watches and webhooks lives in files under ~/.slicc (/home/.slicc), which the user and the agent edit with any tool. The worker watches the folder and reconciles on every change. Each malformed line or file becomes one lick naming the file, the line and the problem, once per distinct problem, with severity="error"; the entry is ignored until it's fixed. Names are 1–64 of a-z 0-9 . _ -, starting with a letter or digit. A target is cone (the active cone) or scoop:<handle>; a lick for a scoop that is stopped or gone goes to the cone, and webhooks always go to the cone. Files starting with . or ending in ~ are skipped; any other file that isn't <name>.json in watches/ or webhooks/ is an error.

  • ~/.slicc/watches/<name>.json: { "path": "~/notes", "glob": "**/*.md", "target": "cone", "message": "…", "debounce": 500 }. path is absolute or starts with ~/; glob (default **) is matched against paths relative to it, with *, **, ?, […] and {a,b}. debounce is 100–60000 ms. node_modules and .git are never scanned.
  • ~/.slicc/webhooks/<name>.json: { "target": "cone", "message": "…" }. A delivery arrives from a kernel process as agent send cone --lick=webhook --source <name> (<message> | --file <path> | -), or from the page as AgentControl.webhook(name, { id, headers, body }); both take the same path (see Sources).

Schedules are the kernel's crontab (slicc-kernel ≥ 1.110.0), not the agent's: each user's table in /var/spool/cron/crontabs/<user>, installed with crontab - and listed with crontab -l. The kernel runs each due job as its user, with $SHELL -c in $HOME, and a job that runs agent send comes back as a cron lick:

# name: standup
*/15 9-17 * * mon-fri  agent send cone --lick=cron "Summarize what changed in ~/notes since the last standup"
# name: backup
@daily                 agent send scoop:ops --lick=cron
  • agent send <target> --lick=cron delivers <lick channel="cron"> instead of a message. It works only inside a kernel cron job: the kernel sets SLICC_CRON_JOB, SLICC_CRON_DUE and SLICC_CRON_MISSED (and SLICC_CRON=1) for every job, and without SLICC_CRON_JOB the flag is an error, which names the plain agent send <cone> … instead. A request from a process that an agent's bash started, or that is in one of its process groups, is refused as well, whatever its SLICC_CRON_* values say. The variables carry the run's metadata but don't choose the mode: a plain agent send in a job sends a message. The lick: source and title are the job's # name: (the kernel's hash of the line without one), the text says when it was due (standup fired at 2026-10-17 11:00), the message is the body, and count is 1 plus the missed runs (standup was due at 2026-10-17 14:00; 2 more runs were folded into this one; the kernel folds runs for more than one reason, such as a closed tab or a replaced schedule, and doesn't say which). Its id is <job>@<due>, so a run is delivered once. The agent script passes the three SLICC_CRON_* values in its request's cron field whenever SLICC_CRON_JOB is set (protocol slicc-agent/2; slicc-agent/1 requests still parse). --lick and --source are argv.
  • Which agent: a job's environment is a login's, so SLICC_AGENT and the page's variables don't reach it. The agent script needs only $USER: it writes to /var/lib/slicc/agent/requests/$USER/, the folder of that kernel user, so the job's user decides who it is. In a cone's crontab, agent send cone wakes that cone and agent send <handle> one of its scoops (a scoop the cone doesn't have falls back to the cone); in root's, cone is the active cone; a scoop's own crontab can only wake the scoop itself. A frozen cone's job is skipped with the usual warn lick.
  • Missed runs, overlap, several tabs and logs are the kernel's: missed runs fold into one, at most once per due time, one tab per storage root fires, and each run's output goes to /var/log/cron/<user>/<name>.log.
  • Moving ~/.slicc/crontab: the agent no longer schedules anything. At start and whenever the file changes, each ~/.slicc/crontab (<schedule> <name> [<target>] [<message>] per line) is moved into the kernel crontab: its jobs become # name: <name> and <schedule> agent send <target> --lick=cron '<message>', replacing jobs of the same names and keeping the rest of the table. A line aimed at a live scoop goes into that scoop's own user's crontab; the others go into the crontab of the file's owner (the cone's user, or root's for /root/.slicc and without kernel users). Then the file is kept as ~/.slicc/crontab.migrated and removed. An empty or blank file is left where it is, so a file caught while it is being written is not moved before its lines arrive. A line that doesn't parse, or a table crontab refuses (it exits 1 and keeps the old table), becomes a cron lick with severity="error" naming the line and the problem, and the file stays until it's fixed. Durable slicc.cron tasks from earlier versions are aborted at start. Jobs an earlier version migrated without --lick=cron get it once per worker start: for each ~/.slicc/crontab.migrated, a job under one of its # name: lines whose command is still exactly the agent send the migration wrote is rewritten in place, whatever its schedule; edited jobs and the rest of the table are left alone. A crontab that can't be read or written becomes a cron lick with severity="error" naming ~/.slicc/crontab.migrated.

Triage. A watch or webhook file can add "triage": "<which events the agent should see>", a sentence of 1–1000 characters such as "pushes to main that touch src/". Then each of its events (a debounced batch of changes, or a webhook request) first goes to Clef Flash (adobe/clef-flash, see Classifiers) with the channel, source, title, text, body (cut at 4000 characters) and up to 50 paths. One choice question asks whether the event matches that sentence: act or ignore. When the answer is ignore with a probability of at least 0.75 (TRIAGE_SKIP), the lick isn't delivered and starts no turn. Instead, a passive slicc.lick-triage entry goes into the target's transcript. It has no model message, so neither the model nor the chat sees it, and it records the would-be lick with the triage sentence, the decision, Clef's confidence and probabilities, the model and the cost. An event whose delivery id was already seen isn't triaged again. A skipped one is remembered only after its record is written, under a requestId taken from the delivery id, so a crash in between neither loses the record nor writes it twice. Triage fails open: without an Adobe sign-in, or when the proxy errors, times out (15 s) or returns any other answer, the lick is delivered as usual. Watch notices (stopped, overflow), config errors, frozen notices, cron