● Built This Week
July 25, 2026

The Apps Introduce Themselves

For the first two months of this suite, wiring two apps together meant editing my code. I picked the endpoint, hardcoded the path, wrote the request, and hoped the other app exposed exactly what I expected. It worked. But it also meant every new feature was a five-app diff: change the consumer, update the provider, add a column to the database, pray to whatever deity watches over zero-downtime migrations.

The Old Way: Blind Probing

The routing layer had two endpoints it would ever call on any connected app: /api/ownlifeos/tasks and /api/ownlifeos/stats. That was the contract. Every app implemented them or nothing happened. If an app renamed its route, added a prefix, or decided to put tasks at /api/v2/, it broke silently, not with an error, but with empty results that looked like "nothing to do" instead of "I stopped working."

It was a system designed for a single maintainer who knew all the conventions. That's me, apparently. But even I change my mind about URLs. And when eight apps all agree on a convention, the cost of changing it multiplies fast.

The New Way: Self-Description

What if an app could just say what it offers? Not a guess-and-poke strategy but a manifest: one JSON object per app, living right next to the code that implements each endpoint. The format is simple: schema version, list of endpoints with their paths and HTTP methods, the fields they return, and the query parameters they accept. No schema registry, no OpenAPI generator, just a constant in each server file.

APP_CAPABILITIES = {
    "schema_version": 1,
    "endpoints": [
        {
            "path": "/api/ownlifeos/tasks",
            "method": "GET",
            "fields_declared": ["text", "source", "priority", "url"],
        },
    ],
    "description": "OwnScribe: live transcription with action items",
}

When you link an app, MyOwnLifeOS hits /api/ownlifeos/capabilities and stores the result alongside the connection record. No magic, just one HTTP call during setup that writes a JSON blob into SQLite. The database got a new column for this: capabilities TEXT DEFAULT NULL, migrated with the usual defensive try/except dance.

Routing Becomes Declarative

This is where it gets interesting. The focus context router used to blindly hit the two hardcoded endpoints on every app. Now it reads each app's manifest and calls only what that app declares. Inbox0 says it has tasks, stats, and accounts, so those three get probed. Almanac says insights and meetings: totally different endpoints, no collision, all queried in the same loop. Summarizer lists four endpoints including meeting-specific ones. Each app speaks its own language and the router just follows the map.

And if an app doesn't declare anything? The fallback kicks in: the old hardcoded paths with a debug log, so nothing breaks for older apps or parse failures. Graceful degradation built into the protocol itself.

The Unexpected Consequence: Less Coupling Than I Feared

I expected self-description to be mostly a developer-experience win: no more editing eight files when I rename a route. The surprise is how much lighter the ecosystem feels architecturally.

The Cost of Not Doing This

I delayed because adding a protocol means maintaining another interface. But the alternative is slower: every feature request meant touching every app that needed data from somewhere else. That's how suites get big without meaning to: not from adding features, but from the coupling cost of *not* having a protocol.

The manifest approach flips that. New capabilities are local to the app that declares them. The router doesn't need to know about tomorrow's endpoints: it just reads whatever's there today. It's what happens when you treat service discovery as a declaration rather than a convention everyone must remember.

See It Live
ownlifeos.com
Invite-only. Currently used by me and my family.
If you are interested, reach out.
#MyOwnLifeOS #ServiceDiscovery #EcosystemArchitecture #SelfDescribingApps #LocalFirst #BuildInPublic
Ovais Quraishi