July 27, 2026

Remote components: borrowing UI from another application

By Manuel Christlieb — Staff Engineer

Part 13 of the Building Lattice series. Part 12 covered testing.

Back in Part 10 I mentioned “an experimental take on pulling a component’s data from a different application entirely, that I’m still not sure has the right shape.” This is that post — and a different kind of post for this series. Remote is a proof of concept. The wire shapes, class names, and the set of remote-capable components may all still change. Building in public means writing about that state too, not just the parts that came out clean.

The idea

One Lattice application embeds a component whose data — or whole schema — comes from a different application. The embedding page describes it like any other component:

DataList::make('todos')
    ->source('workbench.todos')
    ->audience('https://todos.example.test')
    ->scopes(['todos.read'])
    ->dataEndpoint('https://todos.example.test/api/todos')
    ->schema([
        Card::make()->dataBindings(['title' => 'title', 'description' => 'detail']),
    ]);

The design constraint I cared most about: the embedding app never proxies the data. The browser calls the remote service directly. Proxying would make the consumer a confused deputy with access to everything its users can see; instead, the consumer’s only job is to say who is asking and what for.

Short-lived, audience-scoped tokens

It says that with a token exchange. The page payload carries a sealed ref binding the node to its source, audience, and scopes — the same signing mechanism every Lattice endpoint uses. The browser posts that ref to a token endpoint on the consumer (session-authenticated), which refuses on any mismatch between the request and what was sealed, then asks the source definition to mint a token:

#[AsRemoteSource('workbench.todos')]
final class TodoSource extends RemoteSourceDefinition
{
    public function issueBrowserToken(Request $request): BrowserToken
    {
        return new BrowserToken(
            accessToken: '…', // call your real authorization server here
            tokenType: 'Bearer',
            expiresIn: 120,
            audience: $request->string('audience')->toString(),
            scopes: $request->array('scopes'),
        );
    }
}

The browser then calls the remote endpoint with only that Bearer token — cookies explicitly omitted — and the token dies two minutes later. The client caches it per source-audience-scope and refreshes just before expiry. issueBrowserToken() defaults to abort(403): a source that doesn’t opt into minting tokens can’t be talked into it.

The manifest is hostile

A remote service can hand over its whole schema, not just rows — which means the consumer is rendering a UI tree it didn’t author. The resolver treats that manifest as untrusted input: any ref, remote, endpoint, or tokenEndpoint key is stripped and re-stamped with a freshly sealed, server-trusted descriptor, so a remote service can never forge a usable token endpoint. External URLs in the tree are checked against an allowedHosts list before any request is made. Getting this wrong would turn a UI feature into an SSRF-shaped hole, which is why the threat model came before the feature list.

What I’m not sure about

This is the part the series exists for. Honestly:

  • Token issuance is a stub. Every example mints a fake token. The real answer is probably delegating to an authorization server — OAuth token exchange fits the shape — but I haven’t committed to one.
  • Only two components are remote-capable: remote.data-list and the chat box. Whether tables or fragments should ever be is genuinely open.
  • The chat box design bugs me. There’s no remote.chat-box; the regular chat.box becomes remote when you call ->source(). Elegant, or two behaviors hiding in one class? The naming has already been reworked twice, which is usually a sign the seam isn’t right yet.
  • The manifest declares a version nothing reads. Cross-application wire formats need versioning discipline I haven’t designed.

If you have opinions on any of these — especially the token issuance story — this is the post to argue with. The current state, diagrams included, is in the remote docs.

Thirteen parts in, this is the first feature I’ve written up before being sure of it, and it might be the most useful post of the lot to have pushback on. And that chat box that wandered into the story halfway through? It’s doing something more interesting than fetching rows — streaming, tool calls, parts. It deserves its own post.