Stop rebuilding the same integration for every caller
Unified Apps let you write integration logic once in TypeScript and run it in Fused. Your backend, your agents, your webhooks and your schedules all call the same code, and none of them hold a provider key.
We kept running into the same problem while building Fused, and in every product we worked on before it.
Someone writes the "look up this customer" logic in the backend. It calls Stripe, drops the deleted records and maps the fields the support screen needs. A few months later a scheduled job needs the same lookup and gets its own copy. Then the support team gets an AI assistant. The assistant gets raw Stripe tools and works out the steps for itself every time someone asks.
Now there are three versions of one idea. Each has its own bugs and each holds a Stripe key. When the logic changes, someone has to remember all three.
Unified Apps fix this. You write the logic once, in TypeScript, and Fused runs it for you. Your backend calls it over REST or a generated SDK. Your agent calls it through MCP. A webhook or a schedule can start it, and other apps can call it too. You have one implementation to fix and one place where the credentials live.
What one looks like
import * as z from "zod/mini";
import { buildUnifiedApp } from "@fused/unified-app";
import { fused } from "@fused/operations";
export default buildUnifiedApp({
input: z.object({ email: z.string() }),
output: z.object({ customerIds: z.array(z.string()) }),
fetch: { searchable: ["email"] },
async execute({ input }) {
const { data } = await fused.stripe.getCustomers(input);
const customerIds = data.map((customer) => customer.id);
await fused.db.set({ email: input.email, customerIds });
return { customerIds };
},
});
That's the whole app. It comes with a short YAML file that names the services and operations it's allowed to use:
apiVersion: fused/v1
kind: unified_app
name: customer-lookup
version: "1.0.0"
bucket: default
source_path: customer-lookup.ts
services:
stripe:
version: "<enabled-service-version>"
operations: [getCustomers]
A few things are going on in those 15 lines:
- Input and output are typed. Fused checks both on every run, whoever the caller is.
fused.stripe.getCustomersis a generated binding. You call it like an SDK method. Engine adds the Stripe key when the request goes out, so the key never appears in your code, the caller's environment or an agent's context.fused.db.setsaves the result. Callers can look it up later by execution ID, or search byemail, without calling Stripe again.
Why put the logic in Fused
Every caller runs the same code
Each consumer adds the app to its own config and calls it in whatever way suits it:
REST
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":{"email":"sam@example.com"}}'
TypeScript SDK
const result = await sdk.unifiedApps.customer_lookup({ email: "sam@example.com" });
The Python SDK has the same call as sdk.unified_apps.customer_lookup(...).
Agent (MCP)
{
"name": "execute",
"arguments": {
"operation": "unified_app:customer_lookup",
"input": { "email": "sam@example.com" }
}
}
Another app
Apps can call other apps with fused.callApp(...), so a bigger process can reuse customer-lookup instead of writing the lookup again.
When you fix a bug, every caller gets the fix on its next request. You don't need to ship a new SDK, update CI secrets or edit a prompt.
Your agent gets one action, not a pile of endpoints
Give an agent raw API tools and it has to plan the steps on every request: which endpoint comes first, what to filter, when to stop paging. That means more round trips, more tokens, and now and then a step done in the wrong order. The agent can also reach every operation those tools expose.
Give it a Unified App and it calls one action with typed input. The agent decides when to look up a customer, and your code decides how. If something fails, the agent gets the error message you wrote instead of a raw provider response it has to make sense of.
An app can only use the operations you list
The YAML file is the app's whole permission surface. Say the Stripe key in your bucket is allowed to delete customers. If the app only lists getCustomers, it can't delete anything, because Engine rejects any operation the app didn't declare.
Each consumer also gets its own execution token. Your backend, your agent and your scheduled job can all call the app, and none of them holds a Stripe credential.
It can act as each of your users
For OAuth services like GitHub, Google or Jira, your users connect their own accounts. Fused collects anything extra the provider needs, such as a Jira site name, and keeps the tokens refreshed. Inside the app, you choose whose account to use:
const issue = await fused.forUserRef(userRef).jira.getIssue({ issueIdOrKey });
The user reference picks their connection in the bucket. Their token never passes through your code.
It can run on its own, when something happens or on a schedule
Attach a webhook registration to the app and pick the events you care about:
webhook_attachment: team-events
services:
jira:
version: "<enabled-service-version>"
operations: [getIssue]
webhooks: [issue.created]
Once you apply it, Engine starts listening for those events, usually within five seconds. You don't need to run a receiver service or expose a public endpoint. Each event becomes the app's input, and if the provider sends the same event twice, it still runs only once.
Some work isn't triggered by an event. It just has to happen every weekday at nine, or once on the first of the month. For that, give the app a schedule. Open the app in the dashboard and select Schedules, or use the CLI:
fused-cli app schedule create weekly-billing-report \
--name "Weekday report" \
--cron '0 9 * * 1-5' \
--timezone Europe/London \
--input-file ./report-input.json
A schedule can be one-off or recurring, and it runs with the saved input you give it. Fused checks that input against the app's input schema before the schedule is turned on, and stores it encrypted. There's no separate cron service to run. The schedule belongs to the app, so it keeps running after the person who created it signs out, and it always uses the app's active version.
The scheduler is careful about a few things that usually go wrong with home-grown cron jobs. A run never overlaps the previous one. If you run several Engine replicas, only one of them picks up each run. And if Engine was down when a run was due, it does one catch-up run when it's back instead of firing every run it missed. You can pause and resume a schedule at any time.
In every case, whether it's a webhook, a schedule, your backend or an agent, it's still the same app.
A crash doesn't send it back to the start
Some runs work through a list: looking up a batch of customers, or syncing records from one tool to another. If the process dies at item 140 of 200, you don't want to redo the first 139.
Save your progress as you go with snapshot.save(). When the run is retried, Engine loads the latest snapshot and passes it into execute, so your code carries on from where it stopped:
import * as z from "zod/mini";
import { buildUnifiedApp, snapshot } from "@fused/unified-app";
import { fused } from "@fused/operations";
const progress = z.nullable(z.object({ next: z.number(), found: z.array(z.string()) }));
export default buildUnifiedApp({
input: z.object({ emails: z.array(z.string()) }),
output: z.object({ customerIds: z.array(z.string()) }),
recovery: { mode: "automatic", maxAttempts: 3, backoffSeconds: 5 },
async execute({ input, snapshot: restored }) {
// A fresh run starts with null. A retry gets the last saved progress.
const saved = progress.parse(restored) ?? { next: 0, found: [] };
const found = [...saved.found];
for (let i = saved.next; i < input.emails.length; i++) {
const { data } = await fused.stripe.getCustomers({ email: input.emails[i] });
found.push(...data.map((customer) => customer.id));
await snapshot.save({ next: i + 1, found });
}
return { customerIds: found };
},
});
With mode: "automatic", Engine retries a failed or abandoned attempt by itself, up to maxAttempts. Automatic retries are for apps called directly with their own token or started by a webhook or a schedule. If the app is called through an SDK, an MCP server or another app, use the default manual mode, where you start the retry with a rerun request. Either way, the retry uses the original input and the same app version, and every attempt is listed under one run ID, so you can see exactly what happened.
Snapshots are encrypted, and snapshot.save() only returns once Engine has committed the progress. What a snapshot can't do is take back a provider call that succeeded just before the crash. For steps that write to a provider, use idempotency keys or operations that are safe to repeat.
You can change providers without touching callers
Each version of an app is immutable once it's published. Callers on the family URL get whichever version is promoted, and SDK and MCP configs pin an exact version.
Say billing moves off Stripe. You rewrite the inside of customer-lookup against the new provider, swap the service in the YAML, bump the version and apply it. The input is still an email and the output is still a list of customer IDs, so your backend, your schedules and your agent don't change at all. If the new version misbehaves, one command sends new requests back to the old one:
fused-cli app promote customer-lookup --version 1.0.0
You can see what every run did
The app's Requests tab shows each execution: its input and output, any exception with TypeScript line numbers, and every provider request and response the app made, including failed response bodies. A trace breaks each run into phases, and the same timings go out as OpenTelemetry spans if you already have a tracing backend.
That detail is sensitive, so it's encrypted, only visible to people you grant diagnostics access, and deleted 24 hours after the run.
You don't have to write it from scratch
Describe what you want in plain English:
fused-cli describe --name customer-lookup --bucket default \
"Find Stripe customers by email. Accept email. Return the matching customer IDs and save them so they can be searched by email."
Fused finds the services in your workspace (or in the Registry if you haven't added them yet), picks the operations the job needs and drafts the TypeScript. Nothing is deployed until you've reviewed the proposal. The code it writes is ordinary TypeScript that you can edit, so you're never stuck with output you can't change.
When to use one, and when not to
Unified Apps work best for processes with a fixed order and clear rules: onboarding a customer, publishing a release, triaging a new issue, sending a weekly report, syncing records between two tools. They're especially useful for anything you don't want an agent making up as it goes.
They aren't a visual workflow builder, and they don't replace your application's business logic. Each attempt has a short time budget, so they aren't meant for jobs that run for minutes or hours. Snapshots help a run survive a crash, but they don't make it long-running. And if all you need is a single API call, call the operation directly. You don't need an app for that.
Try it
- Install the CLI and sign in.
- Run
fused-cli describewith a process your team has already built more than once, or follow the Unified Apps guide. - Call it from your SDK or agent, or give it a schedule.