Federation Agent Setup — raw technical reference
Purpose: raw technical reference for an agent that configures knowledge sharing between trip2g instances on its own (private-by-default, a shared shared pool, hubs, oversight). A separate pipeline turns this into a user-facing guide for agents; here are all the facts, exact signatures, and edge cases, unpolished.
Status: v0, 2026-06-17. Verified against trip2g code:
- note access:
internal/case/canreadnote/resolve_with_subgraphs.go - federation:
internal/case/mcp/federation_handlers.go,internal/case/mcp/federation_helpers.go,internal/federation/ - frontmatter patches:
internal/frontmatterpatch/evaluate.go - MCP admin tools:
internal/case/mcp/resolve.go
User docs (trip2g.com/docs) referenced:
- federation — federation model end-to-end
- advanced — subgraphs and access control
- frontmatter-patches — frontmatter patches
- hub create — adding a base to a hub (KB-note)
- mcp — local MCP server and tools
- selfhosted — environment variables
1. Control plane: how the agent issues admin operations
Every operation below is admin GraphQL against a specific instance. There are two ways to invoke it; the rest of the document is path-agnostic.
1.1 Via simplepanel (recommended within a pool)
The panel is the trusted control plane: it knows the pool master secret and authenticates as admin into any pool instance over a cookie (HAT → session cookie). The agent works by instance_id and never handles per-instance secrets.
Panel MCP tools (authorized by the panel API key):
| Tool | Args | What it does |
|---|---|---|
list_slots |
— | pool instances (slots): {instance_id, name, domain, ...}. instance_id = slot id |
instance_federation |
instance_id |
instance topology (see §7): self, subgraphs, secrets, KB-notes |
instance_graphql_request |
instance_id, query, variables? |
run admin GraphQL on the instance; the panel proxies it as cookie admin |
Proxy mechanics: the panel signs a HAT JWT ({e: adminEmail, ae:true}) with the instance key, POSTs /_system/hat → session cookie → uses that cookie against /_system/graphql. Authorization on the instance is the usual checkAdmin over the session cookie (admin bypass). The implementation reuses the client in internal/federation/client.go (GetTopology, GraphQL).
1.2 Directly via trip2g MCP (graphql_request)
If someone operates an instance separately (outside the panel pool), trip2g itself exposes admin tools over MCP. This requires an instance API key with enable_mcp_admin_tools=true (mutation setApiKeyMcpAdminTools). Then /_system/mcp offers (see internal/case/mcp/resolve.go):
graphql_introspection(pattern)— find operations/types by regexp before calling;graphql_request(query, variables?)— run a query/mutation as admin.
Throughout the document, "call admin GraphQL X" means either instance_graphql_request(instance_id, X) (panel) or graphql_request(X) (native).
Deployment-dependent. Path 1.1 assumes a deployed simplepanel with access to the pool master secret. Path 1.2 assumes MCP admin tools are enabled on the key. With neither, configuration is done by hand in the admin UI.
2. Privacy model
The unit is an agent with its own knowledge base (a trip2g instance). Tiers:
private— the default. A note with no subgraph should land inprivate(via the patch in §5.1) and be visible only to the agent/admin itself.shared— the common pool for colleagues. A note withsubgraph: sharedis deliberately shared. This is also the default scope of federation links.- oversight — selected readers (management/security) read everything, including
private, through links whose scope includesprivate.
Default topology (low ceremony):
- agents are connected through hub(s) over
shared(instead of an N² direct mesh); - one or a few oversight readers with scope = all subgraphs.
There can be several hubs (e.g. per department); hubs may federate to each other (subject to the depth limit, §8).
3. Access semantics — MUST read
When a federated peer (with a kid) reads a note, trip2g checks (canreadnote/resolve_with_subgraphs.go, allowed = the subgraphs in that kid's scope):
1. note free:true → ALWAYS readable (public)
2. len(allowed) == 0 → NOTHING (except free). Empty scope = NO access
3. note with no subgraphs → readable by ANY peer with a non-empty scope ← IMPORTANT
4. note subgraph ∈ allowed → readable
5. otherwise → not readable
(There is also a nuance in the code: if any of the note's subgraphs is flagged requireSignin, a signed-in reader is allowed — rarely relevant to federation setup, listed here for completeness.)
Two consequences that drive the whole setup:
- (rule 3) A note with no subgraph is visible to any peer with any non-empty scope. So "private by default" holds ONLY when the default-private patch (§5.1) is installed. Without it, granting someone
sharedalso exposes every untagged note. The default-private patch is load-bearing, not optional. - (rule 2) Empty scope = no access, NOT "full access". "Read everything" is expressed by enumerating subgraphs in the scope (including
private); there is no wildcard.
free:true is a separate axis: it makes a note public regardless of subgraphs. For a private internal hub, don't use free on knowledge notes (only, optionally, on the hub's KB-notes themselves — see §5.5).
4. Exact GraphQL signatures (reference)
All admin (cookie bypass via §1). Source: internal/graph/schema.graphqls.
Federation secrets:
# inbound: "someone may read me". kid is REQUIRED; secretHex optional (empty → server generates).
createInboundFederationSecret(input: { kid: String!, description: String, secretHex: String })
-> { id, kid, secretHex } # secretHex shown ONCE
# outbound: "I may read a peer".
createOutboundFederationSecret(input: { kid: String!, secretHex: String!, kbURL: String!, description: String })
-> { id }
# inbound secret scope: which subgraphs the peer sees under this kid (allow-list).
addFederationSecretSubgraph(input: { kid: String!, subgraphID: Int64! }) -> { success }
removeFederationSecretSubgraph(input: { kid: String!, subgraphID: Int64! }) -> { success }
revokeFederationSecret(id: Int64!) -> { revokedId } # select __typename if you don't need revokedId
Subgraphs (read):
allSubgraphs -> AdminSubgraphsConnection { nodes { id, name, color, hidden, requireSignin } }
A subgraph is auto-created when a note carrying subgraph: <name> reaches the instance (there is no separate create mutation). See §5.2.
Notes (write) — creates/updates notes, including arbitrary frontmatter:
updateNotes(input: { changes: [ { upsert: { path: String!, content: String! } } ] }) -> { ... }
# admin via cookie passes (checkapikey admin-bypass). X-Api-Key not needed under cookie admin.
Frontmatter patches:
allFrontmatterPatches -> { nodes { id, includePatterns, excludePatterns, jsonnet, priority, description, enabled } }
createFrontmatterPatch(input: {
includePatterns: [String!]!, excludePatterns: [String!], jsonnet: String!,
priority: Int!, description: String!, enabled: Boolean!
}) -> { frontmatterPatch { id } }
updateFrontmatterPatch(input: {...}) ; deleteFrontmatterPatch(input: { id })
Federation (read your own secrets):
federationSecrets -> [ { id, kid, kbUrl, description, createdAt, revokedAt, subgraphCount } ]
(kbUrl != null → outbound; kbUrl == null → inbound.)
Aggregated (if the panel endpoint exists): GET /_system/federation/admin returns self + subgraphs + inbound/outbound with scope + KB-notes in one call (tool instance_federation).
5. Operation recipes
Each recipe = what to call + verification. All calls go through §1.
5.1 Default-private (load-bearing; do it FIRST on every instance)
Installs a patch: a note with no subgraph gets subgraph: private. Files on disk are not modified (the patch is virtual, applied at load).
createFrontmatterPatch(input: {
includePatterns: ["**/*.md"],
excludePatterns: [],
priority: -100, # applied first
enabled: true,
description: "agent:default-private",
jsonnet: "if std.objectHas(meta, \"subgraph\") || std.objectHas(meta, \"subgraphs\") then {} else { subgraph: \"private\" }"
})
Mechanics (frontmatterpatch/evaluate.go): meta is passed to jsonnet as an object (ExtVar JSON); the result is shallow-merged over the current meta. Returning {} changes nothing → notes with an explicit subgraph are untouched; notes without one get private.
Idempotency: before creating, check allFrontmatterPatches for description == "agent:default-private". If present, don't duplicate.
Verification: allFrontmatterPatches contains the patch; any untagged note now reports subgraph: private.
Caveat (deployment-dependent). If the instance also serves a public site, public notes must have
free: true(it overrides privacy) or add their paths toexcludePatterns. Irrelevant for a purely internal instance.
5.2 Create the shared subgraph (+ sharing policy in its note)
A subgraph materializes by writing a note with subgraph: shared. The note body doubles as the human-readable policy ("what goes here").
updateNotes(input: { changes: [ { upsert: {
path: "subgraphs/shared.md",
content: "---\nsubgraph: shared\n---\nPolicy: put here knowledge that colleagues' agents should find. Everything else stays private by default."
} } ] })
Verification: allSubgraphs contains shared.
The private subgraph already exists after the first private note; a policy note for it is optional (subgraphs/private.md if desired).
5.3 Share a note
The author/agent adds subgraph: shared to the note (via updateNotes upsert with updated frontmatter). Anything untagged stays private (§5.1).
5.4 Private federation edge A→B (scope shared)
Two-step key exchange (as in federation, but automated):
- On B (target) — inbound secret; generate a unique
kid(e.g.<A-name>-<unixtime>):createInboundFederationSecret(input: { kid: "A2026", description: "from A" }) -> { kid, secretHex } - On B — scope the secret to
shared(needssubgraphIDfromallSubgraphs):addFederationSecretSubgraph(input: { kid: "A2026", subgraphID: <shared.id on B> }) - On A (source) — outbound secret to B:
createOutboundFederationSecret(input: { kid: "A2026", secretHex: "<from step 1>", kbURL: "https://B/_system/mcp" }) - On A — the KB-note route (otherwise agent A won't know the path; see hub create):
updateNotes(input: { changes: [ { upsert: { path: "hub/B.md", content: "---\nmcp_federation_kb_url: https://B/_system/mcp\nmcp_federation_kb_id: B\nsubgraph: shared\n---\nWhen to query base B." } } ] })
KB-note visibility (important). A KB-note triggers federated_search only if the searcher on A can see it. So the KB-note's own subgraph must match the searching agent's access: on a shared hub, subgraph: shared (or free: true for a public hub). Do NOT confuse this with the secret's scope (which is about what A reads from B): the secret scope and the KB-note's subgraph are independent.
Verification: on A, federationSecrets contains an outbound to https://B/_system/mcp; on B, an inbound with the same kid and a non-empty scope; federated_search(kb_id="B") from A returns results.
5.5 Hub (instead of an N² direct mesh)
A hub H = an instance whose vault holds KB-notes for all members (folder hub/). For member M:
- M gives H an inbound secret with scope
shared(H reads M'sshared) — §5.4 steps 1–2 on M; - H has an outbound secret + a KB-note
hub/M.mdon M — §5.4 steps 3–4 on H.
Then an agent of any member whose query reaches H (member → H) fans out across all members' shared. Symmetrically, the member also federates to H (KB-note hub/H.md + secrets) so it can search through the hub.
Multiple hubs: H1↔H2 are linked the same way (§5.4). Mind the depth limit (§8) — a hub→hub→member chain counts.
5.6 Oversight (read everything, including private)
For a selected reader O: when issuing it an inbound secret on each instance, add all subgraphs to the scope, including private:
addFederationSecretSubgraph(input: { kid: "OVERSIGHT", subgraphID: <private.id> })
addFederationSecretSubgraph(input: { kid: "OVERSIGHT", subgraphID: <shared.id> })
# ... + the instance's remaining subgraphs
Mark such a secret's description as oversight so visualization (the panel graph) shows it as deliberate privileged access rather than an accidental leak.
5.7 Revoke / narrow
revokeFederationSecret(id: <id>) # full revoke
removeFederationSecretSubgraph(input: { kid, subgraphID }) # remove one subgraph from scope
After a revoke the routing KB-note dangles — delete/rewrite it via updateNotes, otherwise the agent keeps getting 401s.
5.8 Read/verify state
instance_federation(instance_id)(panel) orfederationSecrets+allSubgraphs+allFrontmatterPatches(native).- Health signs: inbound with a (non-empty!) scope, outbound with the same
kid, a KB-note to the samekbURL, the default-private patch in place.
6. Default "team" setup (low ceremony)
The sequence the agent runs across the pool (via list_slots):
- On EVERY instance: §5.1 default-private (otherwise the model is leaky).
- On EVERY instance: §5.2 the
sharedsubgraph with a policy. - Pick hub(s) H. For each member M: §5.5 (M↔H over
shared). - (Optional) Oversight O: §5.6 on all instances (scope = all subgraphs).
- Verify: §5.8 everywhere; on the hub
federated_searchreturns members'shared.
Result: everything private by default; shared visible to the team via the hub; oversight sees everything; nobody touched keys by hand.
7. instance_federation / GET /_system/federation/admin contract
Aggregated read (admin-only), one JSON:
{
"self": { "name", "kb_id", "mcp_url", "subgraphs": [ {id,name} ] },
"outbound": [ { "id","kid","kb_url","revoked_at","subgraphs":[{id,name}] } ],
"inbound": [ { "id","kid","revoked_at","subgraphs":[{id,name}] } ],
"kb_notes": [ { "kb_id","kb_url","description","path" } ]
}
Used by visualization (the simplepanel graph) and by the agent for verification (§5.8).
8. What depends on the deployment
- Fan-out depth —
MCP_FEDERATION_MAX_DEPTH(default 3). Limits hub→hub→member chains and prevents loops. Plan multi-level hubs against the limit. See selfhosted. - Per-peer timeout —
MCP_FEDERATION_FANOUT_TIMEOUT(default 2s). Slow/unreachable peers are skipped. - Default kb_id = host of the instance's public URL (override with
mcp_federation_kb_idin the KB-note). Public URL is instance config. - Transport — HMAC-SHA256 only, JWT exp ~30s, no mTLS/OAuth, no replay cache. TLS is not enforced by the hub — use HTTPS peer URLs in production.
- Control plane — path §1.1 requires a deployed simplepanel with the pool master secret; §1.2 requires a key with
enable_mcp_admin_tools. Without either, configuration is manual. free:trueand public sites — see the caveat in §5.1.
9. Future: a thin CLI
After this playbook is battle-tested, a thin CLI (python/js, minimal dependencies) makes sense — a wrapper over instance_graphql_request/graphql_request with ready subcommands (default-private, make-shared, link, hub-join, oversight, status). It cuts tokens and agent error rates: short commands instead of long GraphQL strings. Build it after "it works, now final fixes" on the core playbook.
10. Link map (trip2g.com/docs)
| Topic | Doc |
|---|---|
| Federation model | /docs/en/user/federation |
| Subgraphs and access | /docs/en/user/advanced |
| Frontmatter patches | /docs/en/user/frontmatter_patches |
| KB-note / hub | /docs/en/hub/_create |
| MCP server and tools | /docs/en/user/mcp |
| Env (depth/timeout) | /docs/en/user/selfhosted |