Search projects, hackathons, sponsors and tech

    ← All projects

    atlas

    Agentic Mini apps on TheGraph -aka dune analytics on steroids

    Demo

    About

    Atlas turns a question about onchain activity into a **mini app**: a live interface generated from real Graph data, that can also hold a wallet and act inside a policy you set. A mini app is not a dashboard. It watches live data, renders whatever interface fits the *shape* of that data, and — if you let it — takes an action when a condition breaks. Every published one gets an ENS name, an address, and an onchain identity, thereby making ENS names as self contained agentic mini-apps themselves. The Graph had everything it needed to be the consumer analytics layer of crypto — more chains, more protocols, real time, decentralized — but it only ever shipped for developers. Atlas is the consumer surface it never built. Because the interface is generated per question, an app exists the moment you ask for it, including for the long tail nobody would ever hand-build. ### The pipeline: a question becomes an app ![Atlas pipeline: question to mini app](https://raw.githubusercontent.com/fabianferno/atlas/main/docs/diagrams/atlas-pipeline.png) The important move is at the **Resolver**: it picks a *schema family* (`lending-cdp`, `dex-amm-extended`, `bridge`, `perp-futures`, …), never a single subgraph id. One question then fires the same query shape at every healthy deployment of that family across every network we have one on, and merges the results. A question nobody anticipated still resolves, because the schema is shared even when the protocol is new to us. A representative run: ``` 18 sources queried → 13 healthy, 5 dead skipped by health check → 74 rows in 2.8s → $0.0014 ``` That ~28% dead rate is real, which is why sources are health-checked at generation time and frozen into the app's manifest. All **16 of 16** seed mini apps in the registry are built by this exact pipeline — resolve, health-check, fan out, compose — not written by hand. Re-measuring every one of them costs **$0.0084** total. ### Three tiers, and depth encodes agency ``` analytics ────────────── monitoring ────────────── autonomous read-only watches + alerts holds a wallet, acts sits flush lifts, live-blue rim stands proud, orange rim ``` The tier is not decoration. An autonomous app always renders its policy strip, its kill switch and its trade log — you can see what a thing is allowed to do by looking at it. The shipped theme is skeuomorphic (Dieter Rams / Braun), and a single `data-skin` attribute re-expresses the whole system in a different material with **no component changes, no catalog changes, no round trip to the agent**. That theme swap is the proof that the generated UI is data, not code. ### The agent cannot inject code The model emits [A2UI](https://github.com/google/A2UI) v0.9.1 — a declarative document plus a data model. The client holds the approved component catalog and the document may only *reference* components by name. For a generated interface that can move money, that containment is the entire safety argument. Form follows data, not keywords: a bounded ratio becomes a gauge, a ranked categorical becomes a leaderboard. The composer reads the shape of what came back, not words in the prompt. ### When it acts, it acts under a gate it cannot talk its way past ![Atlas autonomous loop: Substreams tick to policy gate to signed transaction](https://raw.githubusercontent.com/fabianferno/atlas/main/docs/diagrams/atlas-agency-loop.png) Polling a subgraph means an autonomous guard is up to five minutes late. For an app that displays, that's a preference. For one that spends, it's a correctness bug — so triggers run off a real Substreams gRPC subscription, one tick per block, each carrying the cursor the stream resumes from. Four properties the design turns on: - **A reorg is not an event.** `blockUndoSignal` is journalled and rewinds; it never becomes a trigger signal. The right answer to "that block didn't happen" is to stop, not to act. - **A replayed block does not trade twice.** Substreams replays from a cursor by design after a disconnect, so ticks are keyed `<block>:<hash>` and the ledger dedupes. - **The cursor is committed after the action, not before.** At-least-once with an idempotent key beats at-most-once when the effect is a trade. - **The metric is re-read server-side, per block**, from the app's own health-checked sources. A client that could post `healthFactor` could make an autonomous app trade on demand. The whole chain runs to a real transaction: ``` Arbitrum block 487540654 (real, off arb-one.streamingfast.io) → Substreams tick → trigger "healthFactor < 1.15" fired → POLICY OK approve $25 → 0x036CbD53… (allowlisted, under the $50/tx cap) → session key signed → tx 0x5a44e9d5…9d78 → status success, block 44604106, 1 Approval event → allowance 25 USDC, read back off chain ``` [View it on Basescan.](https://sepolia.basescan.org/tx/0x5a44e9d5d79446afd042928a76d405459242688f479d7257e23143d6190c9d78) It is an `approve`, and it is described as one — granting the router an allowance is genuinely the first step of a swap, but it is not a swap, so `approve` is its own action kind rather than a swap the manifest lies about. ### A name you can verify before you fund it ![Atlas identity: ENS subname, Agentic ID and registry verifying each other](https://raw.githubusercontent.com/fabianferno/atlas/main/docs/diagrams/atlas-identity.png) `atlas-apps.eth` is registered and wrapped on Sepolia and issues a subname per mini app. The ENS record asserts the Agentic ID; the registry on 0G Chain asserts the name; the ERC-7857 token stores the name it was minted against. `mutuallyVerified` is true only when all three agree, and it is **read off the chain on every resolve, never assumed**. Eight subnames are issued; three mutually verify today, and the table in the README says exactly why each of the other five does not — four were published under a previous parent name and their tokens still assert it, and the name↔token binding is deliberately immutable, so renaming a parent cannot drag old tokens along. One (`aave-guard-fork`) is left broken on purpose as the artifact that exposed a swallowed revert in the fork-registration path. The contenthash resolves without us: read `contenthash` off Sepolia, fetch the CID from ipfs.io, parse the manifest. Five of the eight issued names do that today. Three point at bytes that no longer exist anywhere, because they were published while the content store was a gitignored local directory — a CID is the hash of its bytes, so that is unrecoverable rather than pending, and it's stated as such rather than quietly dropped. ### Also shipped - **An MCP server** at `/api/mcp` — the same URL every published app writes into its ENS `agent-endpoint[mcp]` record. Five read-only tools: `list_schemas`, `plan_mini_app`, `query_graph`, `build_mini_app`, `resolve_mini_app`. Nothing there signs or spends, because an MCP endpoint is a URL strangers point agents at. - **A `SKILL.md`** at the repo root so an agent can decide *whether The Graph covers this at all* before trying. - **Policy enforced at the signer, not in the prompt.** Four signer modes behind one interface; in `smart-session` mode (ERC-7579 + Rhinestone Smart Sessions) the allowlist, expiry and lifetime cap are enforced by the account itself. `onchainEnforced` is the return value of a live `isSessionEnabled()` call, never a constant, and the running `session-eoa` mode says plainly that every row is server-enforced. - **159 tests**, 123 of them on the agency layer, including every policy rejection path. ### What is deliberately not built Named explicitly, because a vague scope claim is worse than a small one: a real *swap* from a stream trigger (the transaction is real, the swap is not built); per-account positions (the standardized fan-out reads protocol-level scalars, so `healthFactor` conditions evaluate false and are marked **inert** rather than shown as armed); per-app wallets (one session key currently signs for every app, and the autonomous panel says so); a shared browsable registry (the board is per-browser `localStorage` plus the measured seed snapshot); x402 receipts (implemented, never exercised — no key set); and Subgraph MCP consumption (the env var exists, nothing calls it, so it is a gap, not an integration). The README marks every claim that was verified by reading it back from a chain or a gateway, and strikes through two claims that an audit found overstated rather than deleting them.

    How it's made

    **Stack.** Next.js 16 (App Router) + React 19 + TypeScript, Tailwind v4 with shadcn/ui and Base UI, deployed on Vercel. Contracts are Solidity + Foundry on 0G Galileo (chain 16602). Chain access is viem end to end; Privy handles the human wallet; `permissionless` + `@rhinestone/module-sdk` handle the smart-account signer modes. Zod validates every boundary. No database — state is `localStorage`, IPFS via Pinata, a generated JSON snapshot, and the chains themselves. **The Graph is the load-bearing data source, three products deep.** *Standardized Subgraphs.* [`web/src/lib/kit/sources.ts`](https://github.com/fabianferno/atlas/blob/003cb559cdd9de3edef598b27c65fdaeda2031f3/web/src/lib/kit/sources.ts) is a 96-entry registry — 86 verified deployment IDs across 11 schema families and 4 networks (Arbitrum One, Optimism, Base, Mainnet). [`resolver.ts`](https://github.com/fabianferno/atlas/blob/003cb559cdd9de3edef598b27c65fdaeda2031f3/web/src/lib/kit/resolver.ts) resolves a question to *families*, not ids; [`fanout.ts`](https://github.com/fabianferno/atlas/blob/003cb559cdd9de3edef598b27c65fdaeda2031f3/web/src/lib/kit/fanout.ts) fires one query shape at every healthy deployment and merges. Health checks live in [`sources.ts#L316`](https://github.com/fabianferno/atlas/blob/003cb559cdd9de3edef598b27c65fdaeda2031f3/web/src/lib/kit/sources.ts#L316) with a 90s TTL and a 2s timeout, because a quarter of standardized deployments are dead at any moment and a demo that hides that is lying. *Substreams.* [`web/src/lib/kit/substreams.ts`](https://github.com/fabianferno/atlas/blob/003cb559cdd9de3edef598b27c65fdaeda2031f3/web/src/lib/kit/substreams.ts) is a real gRPC subscription (`@substreams/core` over Connect/HTTP-2) against `arb-one.streamingfast.io`, with a cursor store, `<block>:<hash>` dedupe keys, and `blockUndoSignal` handling. [`scripts/substreams-verify.ts`](https://github.com/fabianferno/atlas/blob/003cb559cdd9de3edef598b27c65fdaeda2031f3/web/scripts/substreams-verify.ts) verifies it **both directions** — a breach run that fires and a control run over healthy blocks that fires nothing, because a harness that can only print ✅ proves nothing: ``` breach: blocks 487508073 → 487508075 in 1.5s → TRIGGER fired → POLICY OK → journaled control: blocks 487509578 → 487509580, healthy throughout → 0 firings ``` *x402.* [`gateway.ts#L233`](https://github.com/fabianferno/atlas/blob/003cb559cdd9de3edef598b27c65fdaeda2031f3/web/src/lib/kit/gateway.ts#L233) implements keyless per-query payment by hand rather than via the client package: POST unpaid, parse the real 402 challenge, answer it with an EIP-3009 signature at the published ~$0.01/query. This is the mechanism that makes forking coherent — a forked app pays with its own wallet instead of inheriting a shared API key. `X402_PRIVATE_KEY` is unset, so no query has actually been paid this way, and the submission says so rather than implying a receipt exists. **0G does the inference and the identity.** Planning and UI composition run on 0G Private Computer (`router-api.0g.ai/v1`, model `0gm-1.0-35b-a3b`) pinned to `verified` routing in [`inference.ts`](https://github.com/fabianferno/atlas/blob/003cb559cdd9de3edef598b27c65fdaeda2031f3/web/src/lib/kit/inference.ts) — the router's default `Standard` mode spans unverified community channels, where `verify_tee` can return no attestation at all. Proof of inference is a chain, not a screenshot: ``` TEE run (tee_verified: true) → request id → manifest.provenance.attestationRef 0g://f1ade7e8-e9ca-4fb2-b5c9-79cd400e3195 → keccak256 0xa651240d…52f1 → MiniAppRegistry.attestationHash ← same value, onchain, bound to token and name ``` [`contracts/src/AgenticId.sol`](https://github.com/fabianferno/atlas/blob/003cb559cdd9de3edef598b27c65fdaeda2031f3/contracts/src/AgenticId.sol) implements the ERC-7857 V2 `eip-7857-draft` interface properly rather than shaped-alike: AES-256-GCM metadata encryption with `dataHash = keccak256(ciphertext)`, ECDSA proofs the onchain verifier actually accepts, receiver binding, stale-metadata rejection, single-use 48-byte nonces. The reference verifier ships two stubs (`verifyPreimage` returns true for any blob; the TEE check is a `// TODO`) and neither was copied. **ENS is the identity layer, not a label.** [`web/src/lib/identity/ens.ts`](https://github.com/fabianferno/atlas/blob/003cb559cdd9de3edef598b27c65fdaeda2031f3/web/src/lib/identity/ens.ts) writes `addr`, `contenthash`, ENSIP-26 `agent-context` and `agent-endpoint[web|mcp]`, and the ENSIP-25 `agent-registration[<erc7930>][<agentId>]` binding, with the ERC-7930 chain-address encoding verified against ENSIP-25's own worked example. [`agentic-id.ts#L1014`](https://github.com/fabianferno/atlas/blob/003cb559cdd9de3edef598b27c65fdaeda2031f3/web/src/lib/identity/agentic-id.ts#L1014) reads the 0G side of the binding back and `mutuallyVerified` is the conjunction of all three assertions. ### The hacky parts worth naming - **ENS on Sepolia does not work the documented way.** `ETHRegistrarController` is not an authorised controller there, so `register()` reverts while `available()` still returns `true` — a silent dead end. The path that works is `TestnetV1PremigrationRegistrar` (`0xdf60C561Ca35AD3C89D24BbA854654b1c3477078`), free, one transaction, `data: []`. Then the name comes back **unwrapped**, so `NameWrapper.setSubnodeRecord` reverts `Unauthorised` until you `setApprovalForAll` + `wrapETH2LD`. That sequence is the single largest time sink of the build and it is written down in [`contracts/deployments/ens-sepolia.json`](https://github.com/fabianferno/atlas/blob/003cb559cdd9de3edef598b27c65fdaeda2031f3/contracts/deployments/ens-sepolia.json) so the next team doesn't lose the same day. - **viem's `getEnsResolver` returns a non-resolver on Sepolia.** For one of our names it answers `0x422484c2…`, where every `addr`/`text`/`contenthash` call reverts; the registry's own `resolver(node)` answers `0xE99638b4…`, which holds the records. We ask the registry first and keep the UniversalResolver path second for wildcard/CCIP names. - **Reading ENS was gated on a write credential.** `resolveRegistrarMode()` degrades to `mock` without a registrar private key, and `readRecords` skipped the resolver entirely in mock mode — so the public deployment resolved *nothing* while working perfectly on the machine holding the key. Reading needs an RPC and nothing else. Same shape as the IPFS bug below: "it resolves on my machine" is not a property of a decentralized record. - **Pinata returns a different CID for the same bytes.** `pinJSONToIPFS` wraps a document as UnixFS and returns a **dag-pb** CID (`bafybei…`) while local mode addresses the raw block (`bafkrei…`). Enabling Pinata naively would have minted new addresses and orphaned every `contenthash` already written to Sepolia. `pinFileToIPFS` with `cidVersion: 1` reproduces the local CID exactly, and [`ipfs.ts`](https://github.com/fabianferno/atlas/blob/003cb559cdd9de3edef598b27c65fdaeda2031f3/web/src/lib/identity/ipfs.ts) now **asserts** it per pin: if Pinata ever returns a CID we did not derive, the publish fails rather than writing an unverifiable address onchain. - **A swallowed revert made every published fork unverifiable.** `MiniAppRegistry.registerFork` reverts `ParentUnknown` when the parent was never published — correct, so lineage can't be faked. But the publish path passed a parent key whenever a manifest carried `forkedFrom`, and every bundled app is unpublished. So: ENS written, token minted, `registerFork` reverts, caught as a generic "registry write failed" — and the 0G half of the mutual proof silently missing. Fixed by asking the contract's own `get(parentKey)` first and registering **without** the lineage link when the parent is genuinely absent, surfacing `lineageSkipped` in the publish warnings. Losing attribution is recoverable from the manifest; losing the registration costs the safety primitive a stranger uses before funding a name. - **Live data broke four things fixtures had hidden.** Placeholder GraphQL in seed manifests was being treated as planner intent and degraded every single-schema app (3 rows where the real query returns 36). `nft-marketplace@2.1.0` denominates in ETH — `cumulativeTradeVolumeUSD` does not exist on `Marketplace`, so asking for it is a hard error that zeroed the whole family. A broken SushiSwap price feed put `$72384163253T` at the top of a leaderboard because the shape detector re-sorted and undid the suspect ranking. And `isActive: true` — the normal state of every healthy market — rendered "Is Active has fired" in an alert banner on a wallet-holding app. [`shapes.test.ts`](https://github.com/fabianferno/atlas/blob/003cb559cdd9de3edef598b27c65fdaeda2031f3/web/src/lib/kit/shapes.test.ts) pins all four. - **A fabricated ticker used to invent money, and it is gone.** A client-side interval made up a block number, a tx hash and a dollar amount every 4.2 seconds and wrote them into stats the board presented as value moved. Deleted. `spentUsd` now comes from the same server-side number the lifetime cap is metered against, so `$0.00` is a measurement. `valueTransactedUsd` had no writer once the ticker went, so it renders nowhere — a figure with no writer is worse than a missing one, because it looks like a measurement. - **Seed triggers that could never fire were being drawn as armed.** The condition grammar was extracted into [`condition.ts`](https://github.com/fabianferno/atlas/blob/003cb559cdd9de3edef598b27c65fdaeda2031f3/web/src/lib/agency/condition.ts) (it imports nothing) and gained `isConditionEvaluable` — the same grammar the evaluator uses, asked a different question. Applying it to the seed apps found **zero of ten evaluable**: four grammatically valid on impossible paths, five unparseable prose, one `null`. All ten were rewritten to protocol-level premises the fan-out genuinely answers, and one that would have alerted on the first block it saw was re-armed with measured headroom. - **Deploying to 0G Galileo needs `--priority-gas-price 2gwei`.** The chain enforces a 2 gwei floor and rejects forge's default estimate outright. - **`pnpm test` runs the typecheck first, on purpose.** `tsx` strips types and runs; it never type-checks, so the suite alone goes green on code that does not compile.