Introducing Unified Apps: one capability, every caller
Write a multi-step integration once in TypeScript. Call it from your SDKs, REST or agents, and swap providers without touching the caller.
Most integrations are a process, not a single call.
Publishing a release means finding the last release, collecting what was merged since, writing notes, creating the GitHub release and announcing it. That's four calls across two services, in a fixed order, with rules between them:
- don't publish the same tag twice
- don't announce a release that doesn't exist
- don't drop changes past the first page of results
Today that process lives in one of two places. Either it's hard-coded into your app, which couples the app to every provider. Or it's handed to an agent with raw API tools, which re-plans the steps on every run.
Unified Apps move the process into Fused. You write it once in TypeScript, and Fused hosts and runs it. Callers send typed input and get typed output. The steps, rules, providers and credentials stay inside the app, so you can change any of them and ship a new version without touching callers.
The same app serves developers, through REST or a generated SDK, and agents, as a tool on an MCP server.
How it works
publish-release (hosted on the Fused Engine)
┌────────────────────────────────────────────────┐
{ repository, │ 1. GitHub list releases → previous tag │ { releaseUrl,
tag } ───▶│ 2. GitHub search merged PRs → changes │───▶ pullRequests,
│ 3. code build release notes │ announcementId }
│ 4. GitHub create release │
│ 5. Resend email the announcement │
└────────────────────────────────────────────────┘
Two files
| File | Contains |
|---|---|
publish-release.ts |
Input schema, output schema, and an execute function with the steps |
publish-release.yaml |
Pinned service versions, the exact operations the app may call, and the credentials bucket |
Every call
- Fused validates the input against the schema.
- It runs
executeon the Engine. - It adds provider credentials server-side as each operation is dispatched.
- It validates the output and returns it.
Credentials never appear in the app's source or the caller's environment.
What it isn't
It isn't a workflow canvas and it isn't a normalized "unified API" schema. It's code you own, deployed as a versioned contract. A run doesn't depend on which caller started it or on how a model planned it.
Build publish-release
Given a repository and a tag, the app:
- Fails if the tag has already been released, and otherwise finds the previous release.
- Finds every pull request merged into
mainsince that release. - Groups the pull requests into release notes.
- Creates the GitHub release.
- Emails the announcement and returns a provider-neutral
announcementId.
1. Describe
With the CLI installed and signed in, describe the process in plain language:
fused-cli describe --name publish-release --bucket default \
"Create a Unified App that publishes a GitHub release and announces it by email.
Accept repository (owner/repo) and tag (vMAJOR.MINOR.PATCH).
List the repository's releases; fail if the tag already exists, otherwise use the latest published release as the base.
Search for pull requests merged into main since that release; fail if there are none.
Build release notes grouped into Features, Fixes and Other using the feature and fix labels.
Create the release on main with those notes.
Then email the notes to releases@acme.dev with Resend.
Return releaseUrl, pullRequests (count) and announcementId.
Keep credentials in the default bucket."
describe finds GitHub and Resend in your workspace, or searches the Registry for them. It then selects the operations the process needs and drafts TypeScript against their contracts. You review the proposal (services, pinned versions, operations and source) before anything is applied. Once you confirm, it activates any missing services, deploys the app and saves the files to .fused/unified_app/.
If you'd rather write the files yourself, use plan and apply.
2. TypeScript
// .fused/unified_app/publish-release.ts
import { z } from "zod";
import { buildUnifiedApp } from "@fused/unified-app";
import { fused } from "@fused/operations";
// Only the provider fields this app relies on.
const releaseList = z.array(
z.object({ tag_name: z.string(), draft: z.boolean(), published_at: z.string().nullable() }),
);
const mergedPRs = z.object({
total_count: z.number(),
items: z.array(
z.object({ number: z.number(), title: z.string(), labels: z.array(z.object({ name: z.string() })) }),
),
});
type PullRequest = z.infer<typeof mergedPRs>["items"][number];
export default buildUnifiedApp({
input: z.object({
repository: z.string().regex(/^[\w.-]+\/[\w.-]+$/), // "acme/web"
tag: z.string().regex(/^v\d+\.\d+\.\d+$/), // "v2.4.0"
}),
output: z.object({
releaseUrl: z.string().url(),
pullRequests: z.number(),
announcementId: z.string(),
}),
async execute({ input }) {
const [owner, repo] = input.repository.split("/");
// 1. Never publish the same tag twice; otherwise start from the last published release.
const releases = releaseList.parse(await fused.github.reposListReleases({ owner, repo, per_page: 20 }));
if (releases.some((r) => r.tag_name === input.tag)) {
throw new Error(`${input.tag} has already been released`);
}
const previous = releases.find((r) => !r.draft && r.published_at !== null);
const since = previous?.published_at ?? "1970-01-01T00:00:00Z";
// 2. Everything merged into main since then. Fail loudly rather than publish partial notes.
const prs = mergedPRs.parse(
await fused.github.searchIssuesAndPullRequests({
q: `repo:${input.repository} is:pr is:merged base:main merged:>${since}`,
per_page: 100,
}),
);
if (prs.total_count === 0) throw new Error(`Nothing merged since ${previous?.tag_name ?? "the first commit"}`);
if (prs.total_count > prs.items.length) throw new Error("More than 100 pull requests; split the release");
// 3. Same notes format for every release.
const notes = releaseNotes(prs.items, previous?.tag_name);
// 4. Create the release.
const release = z.object({ html_url: z.string() }).parse(
await fused.github.reposCreateRelease({
owner, repo, tag_name: input.tag, target_commitish: "main", name: input.tag, body: notes,
}),
);
// 5. Announce it only once the release exists.
const announcementId = await announce(input.repository, input.tag, release.html_url, notes);
return { releaseUrl: release.html_url, pullRequests: prs.total_count, announcementId };
},
});
// The only part of the app that knows how announcements are delivered.
async function announce(repository: string, tag: string, url: string, notes: string): Promise<string> {
const sent = z.object({ id: z.string() }).parse(
await fused.resend.postEmails({
from: "Acme Releases <releases@acme.dev>",
to: "releases@acme.dev",
subject: `${repository} ${tag} is out`,
text: `${notes}\n\nFull release: ${url}`,
}),
);
return sent.id;
}
function releaseNotes(prs: PullRequest[], previousTag?: string): string {
const has = (pr: PullRequest, label: string) => pr.labels.some((l) => l.name === label);
const section = (title: string, items: PullRequest[]) =>
items.length ? [`## ${title}`, ...items.map((pr) => `- ${pr.title} (#${pr.number})`), ""] : [];
return [
...section("Features", prs.filter((pr) => has(pr, "feature"))),
...section("Fixes", prs.filter((pr) => has(pr, "fix"))),
...section("Other", prs.filter((pr) => !has(pr, "feature") && !has(pr, "fix"))),
previousTag ? `Changes since ${previousTag}.` : "",
].join("\n");
}
3. YAML
# .fused/unified_app/publish-release.yaml
apiVersion: fused/v1
kind: unified_app
name: publish-release
version: "1.0.0"
bucket: default
source_path: publish-release.ts
services:
github:
version: "v3"
operations: [reposListReleases, searchIssuesAndPullRequests, reposCreateRelease]
resend:
version: "1.5.0"
operations: [postEmails]
This is the app's entire permission surface: four operations across two services. The GitHub token in the bucket may be able to delete releases or push to branches, but the app can't, because those operations aren't listed.
4. Credentials
describe can enable services, but it can't create API keys. Store a GitHub token that can read pull requests and write releases, plus your Resend key:
fused-cli bucket credential set github --bucket default
fused-cli bucket credential set resend --bucket default
The CLI prompts for each secret without echoing it, and the credentials take effect immediately.
Deploying registers the app but doesn't run it. Save the app family ID and the execution token from the deploy output, because the token is shown only once.
5. Call it
curl -X POST "$FUSED_ENGINE_URL/v1/apps/$FUSED_APP_FAMILY_ID/executions" \
-H "Authorization: Bearer $FUSED_APP_TOKEN" \
-H "Content-Type: application/json" \
-d '{"operation":"execute","input":{"repository":"acme/web","tag":"v2.4.0"}}'
{
"status": "succeeded",
"output": {
"releaseUrl": "https://github.com/acme/web/releases/tag/v2.4.0",
"pullRequests": 23,
"announcementId": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c"
}
}
The response is trimmed. If an execution is still running, look up the result later.
What the code guarantees:
- The order is code. The announcement can't go out before the release exists, and the duplicate-tag check always runs first.
- The edge cases are explicit. A tag that's already released, a release with nothing in it, or results that run past one page all fail with a clear message. None of them produce output that only looks right.
- The contract is provider-neutral. Callers get an
announcementId, not a Resend message ID. That's what makes the next section possible.
Swap a provider without touching the caller
Your team moves release announcements from email to Slack.
If the process lived in your pipeline, that would mean a new SDK, new CI credentials, new code and a redeploy in every place that publishes releases. With a Unified App, the change stays inside the app.
1. Code
Replace the one function that knows about the provider:
// The only part of the app that knows how announcements are delivered.
async function announce(repository: string, tag: string, url: string, notes: string): Promise<string> {
const posted = z.object({ ts: z.string() }).parse(
await fused.slack.chatPostMessage({
channel: "#releases",
text: `*${repository} ${tag} is out*\n${notes}\n<${url}|Full release>`,
}),
);
return posted.ts;
}
2. Config
Swap the service and bump the version:
-version: "1.0.0"
+version: "1.1.0"
...
services:
github:
version: "v3"
operations: [reposListReleases, searchIssuesAndPullRequests, reposCreateRelease]
- resend:
- version: "1.5.0"
- operations: [postEmails]
+ slack:
+ operations: [chatPostMessage]
3. Deploy
fused-cli bucket credential set slack --bucket default
fused-cli app plan -f .fused/unified_app/publish-release.yaml
fused-cli app apply -f .fused/unified_app/publish-release.yaml
Callers using the family URL pick up 1.1.0 with no code change or redeploy.
4. Roll back
Published versions are immutable. Redeploying changed code under an existing version number fails with app_version_immutable, so 1.0.0 stays exactly as it was.
fused-cli app promote publish-release --version 1.0.0
New requests go back to the email version immediately. Your local files aren't touched.
Before (1.0.0) |
After (1.1.0) |
|
|---|---|---|
| Input | { repository, tag } |
unchanged |
| Output | { releaseUrl, pullRequests, announcementId } |
unchanged |
| Caller code | none to change | none to change |
| Provider credentials held by caller | none | none |
One caveat: announcementId keeps its type but not its meaning. It was a Resend email ID and is now a Slack message timestamp. If a caller only logs it, nothing changes. If a caller stores it or looks it up, the swap is a behavior change for that caller.
The rule for any change, whether you're adding a step, changing the notes format or moving off a provider entirely: keep the contract and the behavior callers rely on, including what counts as success and how failures are reported. Test that, then ship a new version.
Don't make your agent rediscover the process
With raw API tools
Prompt: "Release v2.4.0 of acme/web." To get it right, the agent has to work out on its own that it should:
- check that
v2.4.0hasn't already been released - pick the right base release and ignore drafts
- write a valid merged-PR search and notice when results run past one page
- create the release before announcing it
- avoid a second announcement when it retries a failed step
Every run is planned from scratch. Every tool call is another round trip. The agent also holds every operation those tools expose.
With a Unified App
{
"name": "execute",
"arguments": {
"operation": "unified_app:publish_release",
"input": { "repository": "acme/web", "tag": "v2.4.0" }
}
}
The steps, order and edge cases are the same on every run, and the agent holds one action instead of the operations behind it. Failures come back as clear errors, such as Nothing merged since v2.3.0, which the agent can report instead of improvising a fix.
The agent decides when to release. Your code decides how. When announcements move to Slack, the agent's prompt and tool call stay the same, because it never knew about Resend.
One app, every caller
Each consumer selects the app in its own config. The key under unified_apps (publish_release below) is the name that consumer calls it by. Each consumer gets its own execution token, and none of them hold a GitHub, Resend or Slack credential.
TypeScript SDK
apiVersion: fused/v1
kind: sdk
name: release-tools
version: "1.0.0"
language: typescript
bucket: default
unified_apps:
publish_release:
name: publish-release
version: "1.1.0"
After fused-cli app plan and apply:
const result = await sdk.unifiedApps.publish_release({ repository: "acme/web", tag: "v2.4.0" });
if (result.status === "succeeded") {
console.log(`Published ${result.output.pullRequests} changes: ${result.output.releaseUrl}`);
}
The Python SDK exposes the same app as sdk.unified_apps.publish_release(...).
MCP server
apiVersion: fused/v1
kind: mcp
name: release-agent-tools
version: "1.0.0"
description: Release tools for the engineering assistant
bucket: default
unified_apps:
publish_release:
name: publish-release
version: "1.1.0"
Agents call it through the server's execute tool as unified_app:publish_release.
REST
Use the family URL and execution token from the deploy, as in the build steps above. The family URL serves whichever version is currently promoted.
Another app
One Unified App can call another with fused.callApp(...), up to eight levels deep and with at most 32 nested calls.
SDK and MCP consumers pin an exact app version, so upgrading them is a one-line change to the pin. Their application code and agent prompts stay the same. Each config can select up to 16 Unified Apps. See calling from an SDK, MCP or another app.
Good to know
| Topic | Details |
|---|---|
| Retries | Calls are synchronous and aren't retried automatically. Check status before reading output, and design steps so a retry is safe. |
| Partial failure | Here, the duplicate-tag check stops a retry from publishing twice. But if the release succeeds and the announcement fails, a retry stops at that same check. A production version should record whether the announcement went out, so a retry can resume. |
| Composition | Up to eight levels of nested apps and 32 nested calls. |
| Acting as users | For OAuth providers, an app can act on behalf of a connected user instead of using a shared token. |
Get started
Unified Apps are available now.
- Install the CLI and sign in.
- Run
fused-cli describewith the process you want to automate, or start from the Unified Apps guide. - Call it over REST, then give it to your developers and agents.
Start with the process your agents get wrong most often, or the one that would hurt most if you had to change its provider tomorrow.
Join the conversation.
A good story is the beginning of a conversation. Leave a thoughtful note.