@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 unlessstorageis 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;attachApprovalstakes the samenowandsetTimer. Tests pass a fake one and move it forward.openOpfsSqliteStorage({ directory?, file? })keeps the session in wasm SQLite (@sqlite.org/sqlite-wasm) in anopfs-sahpoolpool, 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 (boundedDeltasinsrc/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.jsonnext to the pool,/.slicc/agent-profile.jsonby default:{ "durableSchema": 2, "agent": "<version>" }, with pi durable'sCURRENT_SQLITE_SCHEMA_VERSIONand the agent's version (theagentoption, or the version in the agent'spackage.json). AdurableSchemaalready 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 adurable_schematable, 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'sHTTP-RefererandX-OpenRouter-Title, NVIDIA'sX-BILLING-INVOKE-ORIGINand OpenCode'sx-opencode-client; OpenRouter's preflight allows them fromhttps://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 orrestart(), which end the worker without closing the session) ends instead of being parked. A built-in generation, tool or compaction reportstask_interrupted; any other task reportstask_faultedwith reasonrestart_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'sagent retryis refused.hostAgent(agent)serves the agent from one Chord host namedagent(serveAgentinsrc/host.ts). Its remote contracts, insrc/services.ts, areslicc.agent.control(send withwhenBusysteer,followUporreject, wait, withdraw, abort, compact) andslicc.agent.transcript(the conversation's durable view as replicated state, plusdeliveries: how each user entry was delivered,run,steerorfollow-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 itswhenBusy, placed at once it started a run),slicc.agent.settings,slicc.agent.remote(remote control),slicc.agent.pages(Panels) andslicc.agent.guest(Guest seats).serveConnections(self, host)hands it everyMessagePortthe 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) orguest(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 (AgentPrincipalinsrc/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 hostagent(hostNamed), so no caller names a host. The contracts take their methods' arguments as one JSON array, and the client insrc/serving.tsturns them back into theAgentControl,AgentSettings,AgentRemoteandAgentPagesinterfaces and their replicated states, so callers don't see Chord.@ai-ecoverse/slicc-agent/pagehas 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/spectrumis just as light, and a test keeps both that way.startAgent({ worker })takes the Web Lockslicc-agent, so one tab at a time owns the agent worker, and waits while another tab holds it.connect()returns anAgentConnection(control,transcript,prompt(), andwebhook(name, { id?, headers?, body }), which hands a webhook delivery, such as one from the tray hub, to the licks without a chordContext),restart()replaces the worker (durable resumes from SQLite), andrelease()stops it and lets the next tab take over.port()returns a rawMessagePortinto the worker instead, for another tab: hand it over (through a SharedWorker, say), and that tab callsconnectAgent(port). A caller that already holds the lock passesheld: true. A connection'sclosedresolves 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 })andconnect({ readOnly: true })give a view-only connection, andport({ pages: false })a full one that never takespanelrequests (see Panels).- What a view-only connection can do: it sees the transcript, the agents, settings state and remote-control
watch, and may callwaitandchangesOpen. It's still an owner device, so it has the owner-device methods (OWNER_DEVICE_METHODSinsrc/services.ts) with the owner's powers:decide,guestOpen,guestRevoke,guestAlwaysForgetandapprovalPolicySet. Control gates driving, not approvals. - What it can't: every other
AgentControlmethod, settings action and remote-controlpromptorabortis refused withEACCES("this follower can watch, but not send, steer or abort"). Send and compact answeraccepted: falsewith that code, andaddServerandremoveServeranswernot-owner. Theviewseat's variants don't have those methods at all (viewfor control and settings,watchfor remote control,nonefor Panels); the follower's client answers the missing ones with the refusals (src/readonly.ts), so the worker never runs them. Awatchstream is readable only over the link that opened it. - Carrying the connection over a byte stream:
linkEndpoint({ write, close })is aPortEndpointthatconnectAgentaccepts. Feed it the stream's bytes withreceive(bytes)and its end withend(error?). On the owner's side,bridgePort(port, { write, close })does the same for theMessagePortfromport(). - The bytes: the owner first posts a hello, its server id, then each Chord frame travels as its JSON text in UTF-8 (
PortLinkinsrc/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.sendreturns 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: au32big-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:
connectAgentandconnectGuestclose the link and reject once withcode: '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_requiredorcontract_mismatch; Chord's error is thecause); - 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.
- Chord refuses the handshake because the owner's contracts don't match (
- 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
goneandupgrade_required, which 5.5.2 and later already turn intoEVERSION. 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.
- What a view-only connection can do: it sees the transcript, the agents, settings state and remote-control
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 withspawn({ pgid })(slicc-kernel ≥ 1.19.0, client protocol 1.3), sops -o pgidandkill -- -<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;
- Fallbacks: if the group has no live member left (
- 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 ownfs.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 (EACCESunderusers) 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 andpostFsNotice(it has/proc/sys/kernel/slicc_meta_transactions, read once per client withcat, and that shows shared metadata), recursive targets aren't rescanned at all unlessdeepRescanMsis set, so a writer outside the kernel has to postpostFsNoticeto be heard there. Only a kernel withoutfs.watch(ENOSYS) makes the whole watch poll;watch: { mode: 'polling' }forces that. Polling starts the next scanintervalMsafter 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
coneorcone-<n>is the user of the same name, insudo, with home/home/<cone>(0710) and umask077; - a scoop is
scoop-<handle>, a member of its cone's group, with home/home/scoop-<handle>(2750, the cone's group) and umask027. - So a cone reads its scoops' work, and a scoop reads only what its cone shares with
chmod g+r(andg+xon 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.
- a 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,USERandPATH. The client carries the page's tag for its agentId, so asudoprompt names the agent.The
agentcommand: each user has its own request spool,/var/lib/slicc/agent/requests/<user>/(0700). The spool's owner is the caller, so aSLICC_AGENTthat names someone else is refused.--fileand--system-prompt-fileare read as the caller.Sharing in a sync run: when the caller's cwd, or a
--read-onlypath, is inside the caller's home, the run first shares it with the scoop:chmod -R g+rXon it andg+xon the folders above it. The v6 form's working folder getsg+rwX, plusg+son its folders. The modes stay afterwards.Where things are:
| | | |---|---| | settings, MCP servers,
AGENTS.md, prompts, roles |/etc/slicc/agent/(root);mcp.jsonis the worker's alone: Settings › MCP servers writes it0600 root:root, a file made by hand keeps its mode until that next write, and an agent needssudoto change it | | skills for every agent |/usr/share/skills/(root,0755), as without users | | an agent's own skills |~/.pi/agent/skillsand~/.agents/skillsin its home; only that agent sees them | | memory |/root/.pi/agent/memory/and/root/.pi/agent/agent-memory/(root; agents usememory_writeandmemory) | | licks | each cone's~/.slicc, whoseconetarget is that cone; root's/root/.slicc, whoseconeis 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 ascone-2) and the cone asagentId;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 initis refused when that cone's environment can't be resolved), and its nightly line in the gelatiere scoop's own crontab;gelatiere install,suggestandcatalogrun 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. Withusers: '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
fetchthrough the kernel's transport, whose router picks a linkedsliccexit, the extension or the page's own fetch, and every<port>.kernel.localhostrequest 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 itcrossOrigin: '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
/osand/optas 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
localhostis 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:
contextFillis the context the agent uses, as a share of its model'scontextWindowfrom the settings' model list. The tokens are those of the newest assistant's usage after the active head, counted the way pi-ai'scalculateContextTokensdoes (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).unreadcounts the assistant messages an agent finished while it wasn't the tab'sactive()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, andselect(id)resets it to 0. Each change emitsagents. 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'smeta;read: the path, with its line range asmeta;write: the path;edit: the path, and its change as adiff(spectrum ≥ 1.13.0). While the edit runs, the diff comes from itsoldText/newTextpairs. Once done, it comes from the hunks of the unifiedpatchthat 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,giforwebp) becomes a pi-aiImageContentpart 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 itspathwhen 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'scwd(/homeby default), and the text saysSaved at <path>.A free name gets-1,-2and so on; - for a model whose
inputlacksimage, 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
pathis 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 }.pathis absolute or starts with~/;glob(default**) is matched against paths relative to it, with*,**,?,[…]and{a,b}.debounceis 100–60000 ms.node_modulesand.gitare never scanned.~/.slicc/webhooks/<name>.json:{ "target": "cone", "message": "…" }. A delivery arrives from a kernel process asagent send cone --lick=webhook --source <name> (<message> | --file <path> | -), or from the page asAgentControl.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=cronagent send <target> --lick=crondelivers<lick channel="cron">instead of a message. It works only inside a kernel cron job: the kernel setsSLICC_CRON_JOB,SLICC_CRON_DUEandSLICC_CRON_MISSED(andSLICC_CRON=1) for every job, and withoutSLICC_CRON_JOBthe flag is an error, which names the plainagent 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 itsSLICC_CRON_*values say. The variables carry the run's metadata but don't choose the mode: a plainagent sendin 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, andcountis 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. Theagentscript passes the threeSLICC_CRON_*values in its request'scronfield wheneverSLICC_CRON_JOBis set (protocolslicc-agent/2;slicc-agent/1requests still parse).--lickand--sourceare argv.- Which agent: a job's environment is a login's, so
SLICC_AGENTand the page's variables don't reach it. Theagentscript 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 conewakes that cone andagent send <handle>one of its scoops (a scoop the cone doesn't have falls back to the cone); in root's,coneis the active cone; a scoop's own crontab can only wake the scoop itself. A frozen cone's job is skipped with the usualwarnlick. - 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/.sliccand without kernel users). Then the file is kept as~/.slicc/crontab.migratedand 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 tablecrontabrefuses (it exits 1 and keeps the old table), becomes acronlick withseverity="error"naming the line and the problem, and the file stays until it's fixed. Durableslicc.crontasks from earlier versions are aborted at start. Jobs an earlier version migrated without--lick=cronget it once per worker start: for each~/.slicc/crontab.migrated, a job under one of its# name:lines whose command is still exactly theagent sendthe 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 acronlick withseverity="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
