Vault Access Grants for Telegram Customers
Status: design draft, no implementation. Captures options surfaced during the Business Connection demo on 2026-05-24.
Problem
In the Business Connection demo, the bot replies on behalf of the owner (@rnbwkpr) in business chats and also exposes a /browse wikilink navigator in its DM. Right now navigation is unrestricted: anyone who can DM the bot can walk the full vault. We want the owner to grant a customer access to a specific subgraph of the vault, and the customer to pin that grant in their DM with the bot for self-service.
Use Case
- Owner @rnbwkpr is selling consulting. Vault contains
pricing/,legal/,clients/private/,marketing/. - Customer @jrpcd asks about pricing in a business chat.
- Owner taps "Share Pricing pack with @jrpcd" in their own bot DM.
- Bot generates a grant token, sends a deep link to @jrpcd from owner's account (via business connection): "Self-serve pricing here: t.me/trip2g_dev4_bot?start=grant_abc".
- Customer taps → opens DM with the bot → bot accepts the grant, persists it, and pins a "📁 Pricing pack" entry in their personal menu.
- Customer can later return to the DM anytime, tap the pinned grant, navigate within the
pricing/subgraph only. Wikilinks crossing the boundary appear as 🔒 or are hidden.
Public Opt-In via Frontmatter (Tier 1)
The simplest layer, predating customer-specific grants: a note opts itself into the Telegram navigator with a frontmatter attribute.
---
telegram_navigation: allow
---
Rules:
/browselists only notes that carry this flag (or their reachable subgraph filtered through it)- Wikilinks pointing to notes without the flag stay as plain text in the rendered message — no button is generated and the callback is rejected if forged
- Applies in any chat where the bot is invoked (DM, business chat)
- Does NOT affect site rendering. The Obsidian publisher pipeline ignores this attribute;
[[Plans]]remains a normal wikilink on the website regardless. The flag is metadata consumed only by the Telegram navigation handler.
Bulk application is handled by the existing frontmatter patch system:
# patch: telegram-pricing.yaml
match:
path_prefix: pricing/
set:
telegram_navigation: allow
One patch makes a whole folder publicly browsable from Telegram. Removing the patch removes the access.
This is the default public tier. The grant mechanics below (Tier 2) layer on top for customer-specific scopes that don't belong on the public surface.
Grant Scope Options (Tier 2)
| Option | Definition | Pros | Cons |
|---|---|---|---|
| A. Root + transitive closure | Pick one note, grant access to it plus everything reachable via wikilinks | Zero curation. Matches "this whole topic" intuition | Leaks through unintended links; scope drifts as vault edits link new notes in |
| B. Explicit allowlist | Owner ticks specific note IDs | Maximum precision, audit-friendly | Tedious per grant; doesn't scale beyond a few notes |
| C. Folder / path prefix | Grant = "notes under pricing/**" |
Matches Obsidian's folder mental model; clean revocation; deterministic | Requires publishers to organize by folder (most do anyway) |
| D. Tag / frontmatter property | Grant = "notes with access: customer in frontmatter" |
Cross-folder, works for tag-based vaults | Less intuitive; requires editorial discipline |
| E. Hybrid (C + B overrides) | Folder + explicit excludes/includes | Best of both | More complex UI to express |
Recommendation: (C) as the default with (E) as the power-user escape. Folders are how publishers already think; "share pricing/" is a one-tap operation.
Boundary Behavior for Wikilinks
When the renderer encounters a [[Target]] whose target lies outside the grant scope:
| Strategy | UX | Notes |
|---|---|---|
| Hide the link entirely from text and keyboard | Cleanest, no visual hint of locked content | Owner-side renderings differ from customer-side; might be confusing for the owner inspecting |
| Show as 🔒 in text, no button | Customer sees there's more, can't access | Honest, but invites "give me access too" pressure |
| Show 🔒 button → tap = "Request access" message to owner | Built-in access request funnel | Best for sales flow; needs owner-side UI to approve |
The third option doubles as a lead-generation mechanic, which fits the publishing platform's purpose.
Schema Sketch
create table tg_vault_grants (
id integer primary key autoincrement,
bot_id int not null references tg_bots(id) on delete cascade,
granted_by int not null, -- owner's Telegram user id
granted_to int not null, -- customer's Telegram user id
scope_kind text not null, -- 'folder' | 'note_ids' | 'tag'
scope_value text not null, -- folder path, JSON array of ids, or tag name
label text not null default '', -- human-friendly name shown to customer
token text not null unique, -- random token for deep link
expires_at datetime, -- nullable for indefinite grants
revoked_at datetime, -- nullable
created_at datetime not null default current_timestamp,
last_used_at datetime
);
create index tg_vault_grants_lookup on tg_vault_grants(bot_id, granted_to) where revoked_at is null;
A "pin" in the customer's DM is just a row with granted_to = <customer>. Their /menu (or implicit "no active session") screen lists all active grants for that (bot_id, granted_to) tuple.
Granting Flow (Owner Side)
- Owner is in their bot DM. From
/manageor via a deep link from a business chat (/start bizChat<chat_id>), bot shows:- "Send grant to @jrpcd" button
- Tap → bot asks: which folder? (or: paste path)
- Bot generates token, inserts row, returns a deep link:
t.me/trip2g_dev4_bot?start=grant_<token> - Bot sends the deep link to @jrpcd via the business connection (so the customer sees it as if owner messaged them). Optionally adds a one-line pitch: "Pricing pack — tap to open."
Redeeming Flow (Customer Side)
- Customer taps deep link → Telegram opens the bot DM with
/start grant_<token>. - Bot validates token (exists, not expired, not revoked,
granted_tomatchesfrom.id). - Bot persists the customer's redemption (mark
last_used_at), shows:- "✅ Access granted: 📁 Pricing pack — granted by @rnbwkpr"
- Inline buttons: "Open now" / "Pin to menu"
- Subsequent
/startor/menuin the DM lists pinned grants and lets the customer enter any of them. Navigation is constrained to the grant scope.
Permission Checks in renderNote
Today (demo): every wikilink resolves freely. With grants:
func renderNote(noteID int64, grant *VaultGrant) (text string, buttons []Button) {
// ... existing rendering ...
for _, wl := range parseWikilinks(body) {
targetID := resolve(wl.Target)
if !grant.Allows(targetID) {
// hide, lock, or convert to "request access" button
continue
}
buttons = append(buttons, navButton(targetID, wl.Display))
}
}
The grant struct carries scope_kind + scope_value; Allows(noteID) returns bool based on the note's path/tags/id.
Revocation & Visibility
- Owner sees "Active grants" in
/manage: customer name, scope, last accessed, button to revoke. - Revoke =
update tg_vault_grants set revoked_at = current_timestamp where id = ?. Next browse attempt returns "access revoked". - Optional: notify customer on revoke ("Access to Pricing pack has ended").
Open Questions
- Multi-grant per customer: how many active grants per customer is reasonable to surface in their DM menu? Probably show all, but cap practical limit somewhere.
- Anonymous redemption: should grants be redeemable by anyone with the token, or strictly by the pre-named
granted_to? Pre-named is safer; anonymous is more shareable (good for "magic link" flows). - Notification on first access: ping the owner when a customer first redeems? Probably yes — sales signal.
- Bundles: should "pack" be a first-class entity ("Pricing Pack" composed of folder + 3 individual notes), or always resolve via single
scope_value? Bundles fit reality (sales kits include miscellaneous notes) but add a layer. - Discoverability: should owners be able to publish "self-service" grants on a public landing page (no per-customer ask), where any visitor can redeem? Yes, this becomes a marketing surface.
- Cross-bot grants: scope tied to
bot_id. If publisher has multiple bots (rare today), grants don't cross. Acceptable.
Relationship to Existing Plans
- Builds directly on
tg-handler-routing.mdPhase 0/1 — the navigation handler is the consumer of grants. - Pairs with
telegram_premium_bots.md(Owner-Private Control Surface section) — grants are created from the owner's bot DM, redeemed in customer's bot DM, bridged by deep links. - Pairs with the future agent (
telegram_inbox_agent.md) — agent can auto-suggest "share this folder" when classifying inbound questions.
Future: GraphQL API for Inbox Replies with Navigation
Operators (and the inbox agent) will need a way to programmatically send a navigation-anchored reply into a specific business chat from outside the bot's update loop — e.g. from the admin UI's inbox view or from an autonomous agent processing leads.
Sketch:
type Mutation {
"""
Reply to an inbox message in a Telegram business chat, optionally attaching
a wikilink navigation rooted at the given note path.
"""
tgReplyWithNavigation(input: TgReplyWithNavigationInput!): TgReplyResult!
}
input TgReplyWithNavigationInput {
botID: ID!
businessConnectionID: String! # which connected business owner
chatID: ID! # target customer chat
replyToMessageID: Int # optional: thread the reply
text: String # optional: prefix text above the note
navRootPath: String # vault path to open as the starting note
navMode: TgNavMode # SHOW_BUTTONS | TEXT_ONLY (no keyboard)
}
enum TgNavMode {
SHOW_BUTTONS # render note with wikilink inline keyboard
TEXT_ONLY # render rendered note text without nav buttons
}
type TgReplyResult {
messageID: Int!
errors: [Error!]
}
Under the hood the resolver:
- Validates the caller owns
botIDand thatbusinessConnectionIDis active for them - Resolves
navRootPath→ note ID; rejects if note lackstelegram_navigation: allow(or operator overrides via separate permission) - Reuses the same
renderNotepath as the live handler - Calls
sendMessagewithbusiness_connection_id, persists nav state intg_user_navigation_statesso the customer's taps continue from this seed - Returns the message id so the admin UI can show "sent at HH:MM" and offer "withdraw" via
deleteMessage
This unlocks: agent classifies inbound → picks a relevant note → fires tgReplyWithNavigation → customer continues self-serve from there. Operator can do the same manually from the inbox view.
Pairs with the inbox table from telegram_inbox_agent.md — that's where the source replyToMessageID comes from.
When to Pick This Up
After Phase 1 of tg-handler-routing.md lands (navigation handler + dispatcher + nav state table). Grants are an extension layer, not a foundation. Don't build them until the nav handler is stable.