Repository navigation
0.1.9
Wire any external service to a workspace via webhooks. The tunnel no longer ships hardcoded bitbucket / jira / github providers — instead it hands the raw bytes to your workspace agent, which parses the body and verifies the signature itself. A new wiring-external-webhooks skill walks through the setup end-to-end (#354). OpenRouter joins as an opt-in LLM provider — set OPENROUTER_API_KEY and pick any tool-capable model from their catalog (#348). Installed Friday Studio stops hitting dead ports: workspace imports from the discover catalog and bundled-agent runs both work against the real daemon now (#386), and imported workspaces that reference bundled type: user agents actually run them instead of failing with "agent not found" (#349). Read Breaking changes before upgrading — webhook URLs have moved and the signal-trigger SDK response shape narrows to a discriminated union.
Breaking changes
- Webhook-tunnel collapsed to a single
rawprovider —github,bitbucket,jiraare all gone from the binary.tools/webhook-tunnel/provider/provider.gois now ~25 lines exposingNames() []string { return []string{"raw"} }/IsValid(name string) bool { return name == "raw" }; a newTestIsValidRejectsRemovedProvidersinhandler_test.goexplicitly asserts the three dropped names are rejected. Deleted alongside: the embeddedprovider/mappings.ymlallowlist (-76),provider/extract.go(extractByPath,splitArraySegment, -63) +extract_test.go(-92),provider/handler.go(Verify/Transform/ event-filter pipeline, -143), and the entiretools/webhook-tunnel/passphrase/package (passphrase.go-32,wordlist.go-46,passphrase_test.go-34). TheHandlerinterface,Get(name),List(),Init(),EventMapping,ProviderConfig, and thegopkg.in/yaml.v3dependency are out ofprovider/provider.goentirely. Workspaces previously registered on/hook/bitbucket/<workspaceId>/<signalId>,/hook/jira/..., or/hook/github/...no longer resolve — webhooks must move to/hook/raw/<workspaceId>/<signalId>and the workspace agent parses + (optionally) HMAC-verifies the body against the bytes Friday now hands over byte-for-byte. The/statusJSON response loses itssecretfield;patternis now"/hook/raw/{workspaceId}/{signalId}". The newwiring-external-webhooksskill is the canonical setup guide. (#354) - Webhook-tunnel
WEBHOOK_SECRET+WEBHOOK_MAPPINGS_PATHenv overrides +Config.WebhookSecretstruct field removed. No replacement at the tunnel layer — signature material rides through the proxied request headers (the tunnel deliberately keepsauthorization/cookiebecause Stripe / Slack / etc. carry signature bytes there) and verification happens inside the workspace agent. Per-workspace secret storage belongs in the workspace's own.envoverlay (the supply side shipped in 0.1.8). Thepassphrase.Generate()fallback that previously synthesized aWEBHOOK_SECRETis gone with the package. (#354) - Tunnel forwarder body cap lowered from 25 MiB → 4 MiB.
tools/webhook-tunnel/forwarder/forwarder.goswitches from aForward(...)helper that read+marshalled bodies into a buffer to twohttputil.ReverseProxyreturners —WebhookProxyHandler()(rewrites/hook/raw/...→/api/workspaces/.../signals/...) andProxyHandler()(rewrites/platform/<provider>[/<suffix>]→/signals/<provider>[/<suffix>], preserving query strings for Meta WhatsApp'shub.verify_token/hub.challengehandshake). Bytes are base64-encoded into the JetStream envelope on publish; the 4 MiB ceiling keeps the post-base64 message under the JetStreammax_msg_sizeheadroom (DEFAULT_MAX_MSG_SIZEinsignal-stream.tsraised to 8 MiB in this release). AContent-Length > maxBodySizepre-stream 413 short-circuit lands inhandleHookso oversized uploads fail fast instead of partially streaming. Hop-by-hop header stripping (RFC 7230 §6.1) now comes for free via the stdlib reverse proxy. (#354) WEBHOOK_TUNNEL_URLenv override removed.getWebhookTunnelUrl()inapps/atlasd/src/services/communicator-wiring.tsnow readsEXTERNAL_TUNNEL_URLonly — the prior literalprocess.env.WEBHOOK_TUNNEL_URLis replaced. On installed Friday the launcher (tools/friday-launcher/cert_env.go) remaps webhook-tunnel toFRIDAY_PORT_WEBHOOK_TUNNEL(e.g.:19090) and exportsEXTERNAL_TUNNEL_URL— but neverWEBHOOK_TUNNEL_URL, so the prior variable silently fell through to a dead:9090. New"uses EXTERNAL_TUNNEL_URL when the launcher exports it (installed rig)"test asserts the bare 9090 default is never hit. (#390)WorkspaceRuntime.triggerSignalWithSession/triggerSignalWithResultsignatures changed. Five trailing positional optionals (streamId,onStreamEvent,skipStates,abortSignal,parentSessionId) collapsed into a singleopts: TriggerSignalOpts = {}exported from@atlas/workspace. The bag picks up a newwebhookContext: { body?: string; headers?: Record<string, string> }field threaded intoengine.signal({ type, data, body, headers }). The matching daemon-layer wrappertriggerWorkspaceSignalinapps/atlasd/src/atlas-daemon.ts(+65) takes the same shape. Out-of-tree daemon embedders calling these methods must migrate from positional → options-bag; internal call sites are updated in the same PR. (#354)AtlasClient.triggerSignalreturn type narrows to a discriminated union; oldSignalTriggerResponseSchemain@atlas/client/schemasdeleted. Returns either{ status: "completed", sessionId, output?, summary? }(sync default) or{ status: "accepted", correlationId }(async /?nowait=true/ webhook mode). Callers must narrow onstatusbefore readingsessionId/correlationId. The canonical schema moved to a newpackages/core/src/signal-trigger-response.ts(+32) and is re-exported from@atlas/core(preferred) and@atlas/client(back-compat one-line import path). On response drift the client now throwsAtlasApiError(..., 502)instead of returning a malformed envelope; the previous non-discriminated{ message, status, workspaceId, signalId }shape is gone. (#354)
Added
- Webhook fast path +
?nowait=trueasync mode onPOST /api/workspaces/<id>/signals/<sig>.apps/atlasd/routes/workspaces/index.ts(+345) reads raw bytes viac.req.arrayBuffer(), JSON-parses defensively, and runs a tight envelope discriminator — the request is webhook mode iff the body is non-empty AND has at least one key AND every key is inSIGNAL_ENVELOPE_KEYS(derived fromsignalBodySchema.shapeat module load with an assertion that the set is non-empty, so a zod-upgrade can't silently collapse the discriminator and route every envelope request to webhook mode). Mixed-shape bodies like{"payload": {...}, "action": "opened"}route to webhook mode so zValidator can't strip upstream fields. Hop-by-hop headers (RFC 7230 §6.1 +host/content-length) are stripped before publish;authorization/cookieare deliberately kept because Stripe / Slack / etc. carry signature material there. The body is base64-encoded into the JetStream envelope aswebhookBody+webhookHeaders(new optional fields onSignalEnvelopeSchema); route returns202 { status: "accepted", correlationId }. The same handler honors?nowait=truefor non-webhook callers: publishes to SIGNALS and returns 202 immediately without subscribing to the response stream.Accept: text/event-streamstill wins over?nowait(anindex.test.tstest locks the priority). (#354) envelopeToWebhookContext(envelope)+ raisedDEFAULT_MAX_MSG_SIZEinapps/atlasd/src/signal-stream.ts(+55, plus a new 360-linesignal-stream.test.ts). New pure helper returns{ body, headers } | undefined— explicitlyundefinedrather than{}when neither field is set, so non-webhook signals don't pollutectx.input.raw["body"]with"".SignalEnvelopeSchema+PublishSignalOptsgain optionalwebhookBody(base64) andwebhookHeaders(lowercasedRecord<string, string>).DEFAULT_MAX_MSG_SIZEraised from 1 MiB → 8 MiB to matchFRIDAY_JETSTREAM_MAX_MSG_SIZEinpackages/jetstream/src/config.ts— the old 1 MiB applied only to test-constructed streams and would have let a base64 webhook body pass tests but 500 against a real daemon. Tests round-trip webhook fields through publish → consume → schema and unit-cover all fourenvelopeToWebhookContextshapes. (#354)SignalTriggerResponseSchemashipped from@atlas/coreas the canonical signal-trigger response shape. Newpackages/core/src/signal-trigger-response.ts(+32) + apackages/core/src/signal-trigger-response.tsexport frompackages/core/mod.ts. Discriminated union overstatus—"completed"carriessessionId+ optionaloutput+ optionalsummary(sync);"accepted"carriescorrelationId(async /?nowait/ webhook). Producer (atlasd routes) and every consumer (mcp-serversignals/trigger.ts, atlas-clisignals/trigger.ts, workspace-chatjob-tools.ts,run-job-dialog.svelte,@atlas/clientSDK) zod-parse against the same source. (#354)- FSM engine carries webhook
body/headersthroughinputFromaction chaining.SignalandContext.inputinpackages/fsm-engine/types.ts(+31) gain optionalbody?: string/headers?: Record<string, string>siblings ofdata/config;PrepareResultSchemaadds the same.parsePrepareResultno longer returnsundefinedwhen onlybody/headersare set (previously requiredtaskorconfig, silently dropping__lastPreparecarryover for webhook signals whose downstream code action returned no structural data). Two seams fixed infsm-engine.ts(+44): initialprepareResultfrom a signal propagatessig.body/sig.headersto the top level (not nested underconfig); theinputFrommerge at the second-action-in-a-job path explicitly preservesbody/headersfrom upstream — previously theinputSnapshotspread overwrote them withundefined, so a two-statevalidate → processjob saw the body in action #1 but not action #2. New ~100-line case intests/prepare-result.test.tsdrives a two-state FSM and asserts both actions see the body. (#354) - OpenRouter as a sixth opt-in LLM provider. New
packages/llm/src/openrouter.ts(+38) wraps@ai-sdk/openaiagainsthttps://openrouter.ai/api/v1viacreateChatCompletionsProvider(same rationale as LiteLLM — the Responses API breaks tool-use for non-OpenAI models). SetOPENROUTER_API_KEYand writeconversational: openrouter:anthropic/claude-sonnet-4.6(or any tool-capable slug) infriday.yml.OPENROUTER_PROXY_URLis honored viacreateProxyFetch. The catalog hitshttps://openrouter.ai/api/v1/models?supported_parameters=toolsso the picker isn't drowned by OpenRouter's 500+ slugs, and sendsHTTP-Referer: https://hellofriday.ai/+X-OpenRouter-Title: Fridayattribution headers so usage shows up onopenrouter.ai/rankings.REGISTRY_PROVIDERS/RegistryModelIdextended;PROVIDER_ENV_VARS.openrouter = "OPENROUTER_API_KEY";PROVIDER_META.openroutercarries letterR, prefixsk-or-v1-, help URLopenrouter.ai/keys. Default platform models untouched — pure opt-in. (#348) @friday/wiring-external-webhookssystem skill. New 158-linepackages/system/skills/wiring-external-webhooks/SKILL.mdcovering: URL shape (https://<public-tunnel-url>/hook/raw/<workspaceId>/<signalId>— explicit warning not to give upstreams the direct/api/workspaces/.../signals/...route, which isn't exposed via cloudflared); discovery viacurl -sk "${EXTERNAL_TUNNEL_URL:-https://localhost:9090}/status" | jq(empty body → wrong port for this rig;ps auxww | grep cloudflaredto read the actual--url); thectx.input.config or ctx.input.rawlanding site; the silent-strip foot-gun where a nestedtype: objectwithoutadditionalProperties: trueturns the body into{}for the agent; the tunnel error catalog (400 Unknown provider,400 raw provider expects JSON object,400 empty body,413 body exceeds N bytes,502 Cannot reach atlasd); and aconcurrency: skipfirst-action guard. Cross-referenced fromwriting-workspace-signals(+11) and exercised end-to-end by a newtools/qa/live-daemon/scenarios/webhook-setup-bitbucket.ts(+1867) plus a matching promptfoo trio (webhook-setup-bitbucket-{config,provider,tests}+44/+103/+67) covering 8 contract checks. (#354)- Tier 0 "Reading Signal Payloads" section in
writing-friday-python-agents/references/examples.md(+81). Anti-pattern:parse_input(prompt)scrapes JSON from the enriched prompt string, which holds the action config ({config: {prompt, outputTo, ...}}), not the webhook body — wrong dict for webhook-direct invocations. Correct pattern:payload = ctx.input.config or ctx.input.raw. Includes a full wiredworkspace.ymlexample pinninginitial: idle(notinitial: process) — the "Missing sessionId in signal context" error users hit was caused by parking the agent in the initial-state entry actions, because signal context isn't bound to initial-state entries yet. (#354)
Changed
- Link GitHub provider accepts five new token prefixes.
GitHubSecretSchema.access_token.refine()inapps/link/src/providers/github.tsexpanded fromghp_/github_pat_to also acceptgho_(user-to-server),ghu_(user-to-user),ghs_(server-to-server),ghr_(refresh). Error message rewritten to enumerate all six prefixes. Enables saving tokens minted bygh auth/ GitHub Apps, not just classic PATs. (#354) friday-cliskill rewritten to document the three signal-trigger response modes.packages/system/skills/friday-cli/SKILL.md(+60) splits the previously-single example into explicitly labeled modes of/api/workspaces/<id>/signals/<sig>: (a)?nowait=true→ 202{status:"accepted", correlationId, ...}returning in <100 ms (the mode webhook-tunnel uses internally); (b) sync JSON default → 200{status:"completed", sessionId, output, summary}(connection held up to 10 min, with a warning about upstream 30 s caps); (c) SSE viaAccept: text/event-stream(subscribe-then-publish must happen in one handler — cascade response is core-NATS with no JetStream replay, so a separateGET /signals/stream/<correlationId>would race fast cascades).references/cli.mdagent execno longer hardcodes--url http://localhost:5200— installed Friday Studio uses$FRIDAY_PORT_PLAYGROUND(e.g.:15200), in-tree dev uses:5200. (#354, #390)workspace-chatjob-tools guard against anacceptedresponse. Job tools always run sync (never pass?nowait=true), but the runtime destructureconst { sessionId, status, output, artifactIds, summary } = result.datawould silently produceundefinedsessionId/outputif the route default ever flipped to async.packages/system/agents/workspace-chat/tools/job-tools.ts(+24) now narrows ondata.status === "accepted"before destructuring, logs"Job tool received nowait (accepted) response — expected synchronous completion", and returns{ success: false, status: "accepted", error: "Job '<name>' returned status: accepted. Job tools require the synchronous trigger response (do not pass ?nowait=true)." }. Not breaking — guard surfaces a clear chat-side error if a future route default ever flips, instead of "no sessionId, no output". (#354)- mcp-server signals/trigger goes through a typed mapper. New pure
mapSignalTriggerResponse(workspaceId, signalId, response)inpackages/mcp-server/src/tools/signals/trigger.ts(+80) replaces anas-cast with a runtimeSignalTriggerResponseSchema.safeParse; drift surfaces ascreateErrorResponse(...)to the LLM instead ofas any-laundered garbage. New 96-linetrigger.test.tscovers both union branches + drift rejection (queued/failed/missing-field cases). The atlas-clisignals/trigger.ts(+15) gets the matchingstatus === "completed"discriminator branch, defensive against future shape drift since the CLI never passes?nowait. (#354) - Dependabot auto-sync workflow rebuilt to keep every lockfile and
deno.jsonpin in lock-step withpackage.json. Three follow-on commits compose: (1) newscripts/sync-deno-json-npm-versions.ts(+129) walks everypackage.json(deps/devDeps/peerDeps), keeps the highest semver per name, and rewrites root + workspacedeno.jsononly whenparseRange(existing)can'tsatisfies(parseSemver(target))— caret pins that already cover the bump produce no churn; the existing^/~/ exact prefix is preserved viarangePrefix(). Wired into.github/workflows/dependabot-lockfile.ymlasSync deno.json npm versions+Re-install after deno.json sync; workflow renamed toSync Deno lockfile + deno.json for Dependabot PRs. Fixed dual-package-hazard typecheck failures (Property '[GET_MATCH_RESULT]' missing) that hit every open dependabot PR whendeno.jsonpinnedhono@4.12.18butpackage.jsonwas bumped to4.12.19; same drift had stranded@hono/zod-validator(deno.json ^0.7.5vspackage.json ^0.8.0—^on0.xdoesn't cross minor). (fd42bcec1) (2) Adds rootdeno.json deno.jsoncto thegit-auto-commit-actionfile_pattern—**/deno.jsonglobstar doesn't reliably match the root file. (7ab3f0f26) (3) Adds asetup-node@v6+npm install --package-lock-only --prefix apps/studio-installerstep so the standalone installer lockfile (isolated from the npm workspace) gets regenerated too — without it, #381 and #385 had leftstudio-installer-build.ymlfailingnpm ciacross all three OS jobs withlock file's svelte@5.55.7 does not satisfy svelte@5.55.8. (#388) @anthropic-ai/claude-agent-sdkrolled 0.2.141 → 0.3.143. Labelledsemver-minorby Dependabot but spans a 0.x major-equivalent jump. Transitively bumps@anthropic-ai/sdk0.81.0 → 0.96.0 and pins@modelcontextprotocol/sdkto1.28.0(down from1.29.0). Touchespackages/bundled-agentsandpackages/llm. Pre-1.0 SDKs typically carry API-surface changes between minors; if you've forked or extended the bundled-agent surface, smoke-test before upgrading. (#384)@hono/mcp0.2.5 → 0.3.0 acrossapps/atlasdandapps/link. Pre-1.0 minor bump on the MCP server-wiring surface; also pullshono-rate-limiterin transitively. (#374)chat+ adapters (@chat-adapter/slack/discord/teams/telegram/whatsapp) 4.28.1 → 4.29.0 acrossapps/atlasd. Minor bump but broad runtime surface — worth a smoke test on any communicator wiring. (#375)
Fixed
- Tunnel forwarder switched to
httputil.ReverseProxyfor byte-faithful pass-through.tools/webhook-tunnel/forwarder/forwarder.goswaps aForward(...)helper that read-and-marshalled bodies for two stdlib reverse-proxy returners —WebhookProxyHandler()andProxyHandler(). Method / body / headers / query string preserved; only host + path rewritten. The prior buffer-and-remarshal path made signature verification againstr.Bodybytes impossible (header / body whitespace was lost on the rewrite); reverse-proxy passes bytes through unmodified, which is what unlocks the workspace-side HMAC verification the new skill teaches. (#354) - Playground discover + execute routes resolve the daemon URL at runtime instead of build-time.
tools/agent-playground/src/lib/server/routes/{discover,execute}.tsimported the build-time-frozenDAEMON_BASE_URLconstant — vite froze it from__FRIDAY_DAEMON_BASE_URL__atnpm run build, and since the production build runs withoutFRIDAYD_URLset, the baked value was the fallbackhttp://localhost:8080. The launcher then started the real daemon onhttps://localhost:18080, so every workspace import from the discover catalog and every bundled-agent run hit a dead port — UI surface was "Import failed (500)". NeweffectiveDaemonUrl()/effectiveTunnelUrl()insrc/lib/server/daemon-url.tsread vite's define in dev and fall through to runtimeFRIDAYD_URL/EXTERNAL_TUNNEL_URLin the compiled binary; the trap constantsDAEMON_BASE_URLandTUNNEL_BASE_URLare deleted. Same fix threads intostatic-server.ts(inlineEXTERNAL_TUNNEL_URLread replaced with helper) and the dev catch-all proxies/api/daemon/[...path]+/api/tunnel/[...path]. (#386) - Workspace bundle imports install bundled
type: useragents into the global agents dir.POST /import-bundleandPOST /import-bundle-allhad extracted bundled user agents to<targetDir>/agents/<name>/(workspace-local) but never copied them to<atlasHome>/agents/<id>@<version>/— the only pathUserAdapterscans — and never calledregistry.reload(), so imported workspaces that referencedtype: useragents failed at runtime with "agent not found." NewinstallImportedAgentsinapps/atlasd/routes/workspaces/bundle-helpers.ts(+182) readsmetadata.json, atomic tmp+rename copies into the global dir, callsregistry.reload()after either endpoint installs anything new; import responses gainagentsInstalled/agentsSkippedcounts. Three hardenings ride along: (1)isPathSafeIdentrejects/,\, NUL,..,., leading/trailing dots, >128 chars before joining — blocks a malicious bundle whosemetadata.json.id = "../../etc/cron.d/x"from writing outside the agents root, per-agent skip not whole-import abort; (2) on<id>@<version>already-installed collisions,hashPrimitivecompares content and emits aWARNwhen divergent (previously silent: workspace B with the same id@version but different code would run workspace A's bytes); (3) reusesAgentMetadataFileSchema(now re-exported from@atlas/core/agent-loader/index.ts) instead of ad-hoctypeofchecks. Closes the export-side gap fromresolveExternalUserAgents(#288). (#349) @ai-sdk/mcppinned to1.0.41—1.0.42broke every HTTP MCP connection.@ai-sdk/mcp@1.0.42assignsthis.transport.protocolVersion = result.protocolVersion, butStreamableHTTPClientTransportexposesprotocolVersionas a getter only — so every HTTP MCP server connection threw"Cannot set property protocolVersion of #<StreamableHTTPClientTransport> which has only a getter"and the server was skipped.packages/mcp/package.jsonpinned to1.0.41;.github/dependabot.ymlgains anignoreblock for@ai-sdk/mcp >= 1.0.42plus a new@tanstack/*group soquery-coreandsvelte-querymove together. Secondary fallout patched: PR #364 had bumped@tanstack/query-coreto5.100.10whilesvelte-query@6.1.28still bundled5.100.9, collapsing twoQueryFunctiontype identities and producing ~40createQueryinference errors acrossagent-playground— fixed by bumping@tanstack/svelte-queryto6.1.29.createMCPToolsalso logserr.causeon swallowed connection errors so wrapped subprocess stderr is visible without patching. (#370)- MCP tool invoker and usage panels render the resolved workspace display name. Both
mcp-tool-invoker.svelteandmcp-workspace-usage.sveltecalledworkspaceQueries.list()and bound to the raw daemonnamefield. For templated workspaces the defaultnameis the literal stringworkspace, so the dropdown surfaced two indistinguishableworkspaceentries — users couldn't tell which workspace they were invoking a tool against. Switched toworkspaceQueries.enriched()+ws.displayName, matching the sidebar / command palette / other pickers. (#353)
Deps
- Routine patch / minor bumps batched by Dependabot:
ai-sdkgroup across two rounds (ai6.0.177→6.0.184,@ai-sdk/anthropic3.0.76→3.0.78,@ai-sdk/google3.0.71→3.0.75,@ai-sdk/openai3.0.63→3.0.64,@ai-sdk/svelte4.0.177→4.0.184) (#357, #377);@vitest/coverage-v8+vitestpatch bumps (#358, #367);@tanstack/svelte-tablealpha.41 → alpha.46 → alpha.48 over two PRs (#361, #378);@sveltejs/kit2.59.0 → 2.60.1 (#363);svelte5.55.7 → 5.55.8 (#381);@tanstack/query-core5.100.8 → 5.100.10 (#364);react19.2.5 → 19.2.6 (#362);xstate5.31.0 → 5.31.1 (#359);vite7.3.2 → 7.3.3 (#385);prettier-plugin-svelte3.5.1 → 3.5.2 (#383);svelte-eslint-parser1.6.0 → 1.6.1 (#365);typescript-eslint8.59.2 → 8.59.3 (#366);knip6.11.0 → 6.14.0 (#360);dompurify3.4.2 → 3.4.5 (#379);@libpdf/core0.3.4 → 0.3.6 (#380);yaml2.8.4 → 2.9.0 (#382);@hono/zod-validator0.7.6 → 0.8.0 (#356);hono4.12.18 → 4.12.19 (#374);@anthropic-ai/claude-code2.1.137 → 2.1.143 in/docker(#355); Tauri group inapps/studio-installer/src-tauri(#373);github.com/aymanbagabas/go-pty0.2.2 → 0.2.3 (#372).