# Fused

> The only integration gateway you need

Publication: https://usefused.com/blog/

---

# Introducing Unified Apps: one capability, every caller

Canonical: https://usefused.com/blog/post/unified-apps
Readable HTML: https://usefused.com/blog/read/unified-apps
Markdown: https://usefused.com/blog/posts/unified-apps.md
Published: 2026-10-16T14:00:53.696Z
Updated: 2026-10-23T01:32:36.351Z
Tags: Unified-apps, Integration

> 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

```text
                    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             │
                  └────────────────────────────────────────────────┘
```

[Tabs title="Anatomy of a Unified App" variant="boxed"]
[Tab title="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 |

[/Tab]
[Tab title="Every call"]

1. Fused validates the input against the schema.
2. It runs `execute` on the Engine.
3. It adds provider credentials server-side as each operation is dispatched.
4. It validates the output and returns it.

Credentials never appear in the app's source or the caller's environment.

[/Tab]
[Tab title="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.

[/Tab]
[/Tabs]

## Build `publish-release`

Given a `repository` and a `tag`, the app:

1. Fails if the tag has already been released, and otherwise finds the previous release.
2. Finds every pull request merged into `main` since that release.
3. Groups the pull requests into release notes.
4. Creates the GitHub release.
5. Emails the announcement and returns a provider-neutral `announcementId`.

[Tabs title="publish-release, end to end" variant="boxed"]
[Tab title="1. Describe"]

With the [CLI installed and signed in](https://usefused.com/docs/quickstart), describe the process in plain language:

```bash
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](https://usefused.com/docs/app/unified-apps/write-code).

[/Tab]
[Tab title="2. TypeScript"]

```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");
}
```

[/Tab]
[Tab title="3. YAML"]

```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.

[/Tab]
[Tab title="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:

```bash
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.

[/Tab]
[Tab title="5. Call it"]

```bash
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"}}'
```

```json
{
  "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](https://usefused.com/docs/app/unified-apps/results-and-debugging) later.

[/Tab]
[/Tabs]

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.

[Tabs title="Resend → Slack" variant="boxed"]
[Tab title="1. Code"]

Replace the one function that knows about the provider:

```typescript
// 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;
}
```

[/Tab]
[Tab title="2. Config"]

Swap the service and bump the version:

```diff
-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]
```

[/Tab]
[Tab title="3. Deploy"]

```bash
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.

[/Tab]
[Tab title="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.

```bash
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.

[/Tab]
[/Tabs]

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

[Tabs title="The same job, given to an agent" variant="boxed"]
[Tab title="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.0` hasn'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.

[/Tab]
[Tab title="With a Unified App"]

```json
{
  "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.

[/Tab]
[/Tabs]

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.

[Tabs title="Calling publish-release" variant="boxed"]
[Tab title="TypeScript SDK"]

```yaml
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`:

```typescript
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(...)`.

[/Tab]
[Tab title="MCP server"]

```yaml
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`.

[/Tab]
[Tab title="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.

[/Tab]
[Tab title="Another app"]

One Unified App can call another with `fused.callApp(...)`, up to eight levels deep and with at most 32 nested calls.

[/Tab]
[/Tabs]

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](https://usefused.com/docs/app/unified-apps/use-from-apps).

## 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](https://usefused.com/docs/bucket/connect-user-accounts) instead of using a shared token. |

## Get started

Unified Apps are available now.

1. [Install the CLI and sign in](https://usefused.com/docs/quickstart).
2. Run `fused-cli describe` with the process you want to automate, or start from the [Unified Apps guide](https://usefused.com/docs/app/unified-apps).
3. Call it over REST, then [give it to your developers and agents](https://usefused.com/docs/app/unified-apps/use-from-apps).

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.

