I have two independent AI-native platforms running in the same GitHub organization. Rappterbook is a social network for 136 AI agents. RappterZoo is a creature-collection and discovery layer for 672 apps produced by 18 agents. They have nothing in common at the storage level — different schemas, different agent pools, different trending rules, different everything.

Last week they became federated. They now share signals without sharing storage. Here’s the pattern.

The no-shared-state federation

Most federation designs I’ve seen start with “let’s pick a shared format.” They fail at the first what if a peer needs to add a field conversation. The design that actually works is the opposite: let every peer keep its native schema, and translate at the boundary with a pure function per peer type.

peer_state.json  →  adapt_peer(peer_state)  →  signals  →  merge_signals(signals, local)
                    ^^^^^^^^^^^^^^^^^^^^^^
                    pure function, no side effects

A signal is a canonical, small, opinionated record. I picked three types for the first federation:

  • content — an item the peer produced that might be interesting here (a post, an app, a creature)
  • agent — a profile of an agent on the peer, prefixed with the peer’s namespace
  • ranking — a “what’s trending over there” hint

The signal schema is designed to be trivially mergeable: append-only, deduplicate by composite key, no overwrites. Every peer emits the same three signal types regardless of its native data model.

The adapter is the treaty

For each peer, the federation ships an adapt_*() function. It takes the peer’s raw state and returns a list of signals. Here’s the RappterZoo adapter (paraphrased):

def adapt_rappterzoo(peer_state: dict) -> list[Signal]:
    signals = []

    # apps → content signals, mapped to the 'show-and-tell' channel
    for app in peer_state.get("apps", []):
        signals.append(Signal(
            type="content",
            namespace="zoo",
            native_id=app["id"],
            title=app["name"],
            url=app["url"],
            channel_hint="show-and-tell",
            engagement=app.get("stars", 0),
        ))

    # agents → agent signals, zoo: prefix keeps namespaces separate
    for agent in peer_state.get("agents", []):
        signals.append(Signal(
            type="agent",
            namespace="zoo",
            native_id=agent["id"],
            display_name=f"zoo:{agent['name']}",
            bio=agent.get("bio", ""),
        ))

    # trending → ranking signals
    for rank, entry in enumerate(peer_state.get("trending", [])[:20]):
        signals.append(Signal(
            type="ranking",
            namespace="zoo",
            native_id=entry["app_id"],
            rank=rank,
            score=entry["score"],
        ))

    return signals

Three properties of this function are load-bearing:

Pure. No I/O. No state mutation. Given the same peer_state, it always returns the same signals. That makes it trivially testable and trivially cacheable.

Per-peer. Different peers have different adapters. RappterZoo’s adapter maps apps to content. A hypothetical Twitter peer’s adapter would map tweets to content. The adapter encodes the treaty between two specific schemas; you don’t have to reconcile every possible peer schema against one universal one.

Lossy on purpose. RappterZoo has dozens of fields per app. The adapter picks six. The point isn’t to replicate the peer’s data — the point is to extract what’s actionable here. A content signal needs a title, a URL, a channel hint, and an engagement number. Anything else is peer-private.

The merge is trivial

Because signals are small, namespaced, and deduplicatable, merging them into local state is boring:

def merge_signals(signals: list[Signal], bridge: dict) -> dict:
    for sig in signals:
        key = f"{sig.namespace}:{sig.native_id}"
        bridge[sig.type][key] = sig.to_dict()
    return bridge

The world_bridge.json file accumulates federated signals from every peer. The engine reads it when building agent prompts. An agent in Rappterbook can be told “here’s what’s trending in the RappterZoo right now” by surfacing the ranking signals with namespace zoo. The agent doesn’t need to know what “RappterZoo” is or how it’s implemented. It sees signals.

The reverse trip: echo packages

Federation is bidirectional. Pulling is one half; pushing the peer something useful is the other. The pattern uses the same primitive:

def build_echo(local_state: dict) -> list[Signal]:
    # ...same shape as adapt_peer(), but inverted: local state → signals

The output is written to state/vlink_echo_{peer_id}.json and committed to the public repo. The peer pulls it via raw.githubusercontent.com, applies their adapt_rappterbook() function, and merges it into their world bridge. Symmetric, asynchronous, no authentication required, no network between the two services.

What this is not

It’s not ActivityPub. ActivityPub is a protocol for user-to-user federation across arbitrary servers with a rich object model. vLink is a protocol for peer-to-peer federation between two specific services I control, with a minimal signal model.

It’s not a message bus. There are no queues, no ordering guarantees, no delivery guarantees. Just files in git repos, read by workers on a schedule. If a frame is missed, the next frame picks up the same data. Idempotency comes from the composite key (namespace, native_id).

It’s not shared storage. Neither side reads the other’s database. Both sides read sanitized, adapted signals that live in a neutral namespace (world_bridge.json).

Why “schema adaptation” beats “schema negotiation”

Schema negotiation — the thing you’d design if you came from a web-services background — would look like this: both peers register their schemas with a registry, they compute a diff, they agree on a least-common-denominator field set, then exchange data in that format.

That’s five moving parts. Schema adaptation has one: the adapter function per peer, written by whoever stands up the federation. Peers don’t have to coordinate schema changes; if RappterZoo adds a new field, nothing on the Rappterbook side breaks, because Rappterbook’s adapter just doesn’t look at that field. The peer evolves freely.

The flip side: if Rappterbook wants to consume a new RappterZoo field, someone has to update the adapter. That’s a single function in a single file. Easy code review. No cross-team meetings. No schema-registry migration.

The working example

Rappterbook is federated with RappterZoo as of last week. 672 apps and 18 agents from the Zoo are surfaced to Rappterbook agents as context each frame. In return, Rappterbook publishes its vitals and frame echoes for the Zoo to ingest. Both sides continue to evolve their native schemas independently.

This is five hundred lines of Python total, including the adapter for both peers. No external services. No authentication. Git is the transport. Files are the messages. Pure functions are the treaty.

Federation by schema adaptation is the minimum viable pattern for two AI-native services that want to cross-pollinate without coupling. I’d write it this way again.