Vibes DIY
Vibes DIY / Docs
creator docs · connections

Connect your visitors' accounts

Some apps are more useful when they can act as the person using them — read their GitHub profile, greet them by their real handle there, show the posts they already published somewhere else. Doing that yourself normally means an OAuth client, a secret, a redirect route, token storage, and a refresh loop.

On Vibes DIY you write none of that. Your app declares which provider it wants, the platform asks the visitor, holds the credential, and lets your backend read through it. There is no token in your code, no secret to store, and nothing to rotate.

Three providers are connectable: GitHub, Threads and Instagram. Your app can read from all three, and post to Threads and Instagram when the visitor approves a scope that says so — see "Posting on someone's behalf" below. Each provider goes live only once its credential is provisioned on the platform, so a provider your app declares today may not be connectable everywhere yet; the declaration is accepted either way and the connect button starts working when the provider does.

The three moving parts

  1. You declare it in backend.js, in a config.connections block. That declaration is checked when you push and is the only statement of what your app may ever ask for.
  2. The visitor approves it. Your app calls link() from the useConnection hook; the platform shows its own consent card and runs the sign-in.
  3. Your backend reads it with the @vibes.diy/social library — typed calls like instagram(ctx).media() — or, underneath it, with the raw ctx.connections.fetch(...). Either way a call returns real data or refuses with one of a small set of named reasons.

1. Declare the connection

jsexport const config = {
  connections: [
    {
      provider: "github",
      scopes: ["read:user"],
      why: "Show your GitHub profile next to your entries.",
    },
    {
      provider: "instagram",
      scopes: ["instagram_business_basic"],
      why: "Pull your latest posts into your board.",
    },
  ],
};

Three fields, and each is checked when you push:

  • provider"github", "threads" or "instagram". Anything else is rejected at push time rather than failing later at runtime. Name a provider once: two entries for the same provider are rejected, so one scope list and one reason govern the grant.

  • scopes — optional. What each provider accepts:

    Provider Scope What it reaches
    github read:user Read their GitHub profile.
    threads threads_basic Read their Threads profile and their own posts.
    threads threads_content_publish Post to their Threads feed.
    instagram instagram_business_basic Read their Instagram profile and their own posts, with like and comment counts.
    instagram instagram_business_content_publish Post a photo or video to their Instagram.

    A scope that posts is its own scope, and declaring it is the whole ask — the visitor reads "Post to your Threads feed as you" on the consent card, marked as changing their account, and decides. Declare one only when your app really posts.

    An unrecognised scope — or a real scope belonging to a different provider — is a push-time rejection too, because the consent card shows the visitor what they are agreeing to and a scope nobody validated is a promise nobody checked.

  • why — required, and non-empty. This sentence is shown to the visitor, in your words, on the consent card. A consent prompt that can't say why isn't consent.

All three must be plain string literals in the config object you export from backend.js. The declaration is read statically, so a value your code computes at runtime can't be read and won't be accepted.

Instagram connects through Instagram Login, not through Facebook — the visitor signs in with Instagram itself. That flavor needs a professional account (Business or Creator) on their side; a personal account can't complete the connect.

What a bad declaration looks like

Validation happens at push, before anything is deployed, and the error comes back on the push itself prefixed with backend.js:. For example, asking for a scope that doesn't exist:

backend.js: config.connections must be an array of { provider: "github",
scopes: ["read:user"], why: "…" } with static string literals —
config.connections[0].scopes "repo" not available for provider "github";
the allowed scopes are "read:user"

Nothing is deployed when a declaration is rejected, so a failed push leaves your live app exactly as it was.

2. Ask the visitor, from your app

Inside the app, useConnection gives you the state and the button:

jsximport { useConnection } from "use-vibes";

function InstagramBadge() {
  const { state, consented, accountLabel, link, refresh } = useConnection("instagram");

  if (state === "pending") return <p>Checking…</p>;
  if (state === "absent") return <button onClick={link}>Connect Instagram</button>;
  if (state === "dead") return <button onClick={link}>Reconnect Instagram</button>;
  if (!consented) return <button onClick={link}>Allow this app to use Instagram</button>;

  return <p>Connected as {accountLabel}</p>;
}

What the hook gives you

Value What it means
state: "pending" We don't know yet. The first read hasn't landed.
state: "absent" No credential at all. Connecting is a full sign-in.
state: "live" Connected and usable.
state: "expiring" Still usable, but expires within two weeks.
state: "dead" Revoked or expired. A re-link, not a first link.
consented Whether this app may spend the credential.
accountLabel The provider-side login, e.g. a GitHub username. Display only.
expiresAt ISO timestamp. Absent means no stated expiry, never expired.
link() Opens the platform's consent flow.
refresh() Re-reads the status.

Three things about this shape are worth internalising, because guessing at any of them produces a real bug:

pending is not absent. They are opposite instructions to a person: absent means "connect your account", pending means "we haven't found out yet". Rendering a Connect button while the answer is in flight tells an already-connected visitor they are disconnected and invites a pointless second sign-in. Show a quiet checking state while pending.

state and consented answer different questions. A live account with consented: false is a one-click ask — the person is already signed in to the provider, they just haven't allowed your app. An absent account is the whole sign-in round trip. Fold them together and you draw the wrong button.

link() resolving does not mean the account is linked. The consent card and the sign-in both happen on the platform — the card in the page around your app, the provider's authorize screen in a window your app can't see — so your app only ever learns that the flow started. There is no completion event to await. If you want to react to completion, poll refresh() every few seconds while your surface is visible.

link() and refresh() never throw and never reject. A visitor who declines, a provider your app never declared, and a round trip that fails all leave the current state exactly as it was. Someone saying no is a normal answer, not a fault.

What the visitor sees

The consent card is drawn by the platform from your published declaration — not from anything your app sends — so what it says and what your backend can later do cannot disagree. It names your app, quotes your why verbatim and attributes it to you, and is explicit that this is the visitor's own account:

Connect your GitHub account?

you/potluck wants to use your own GitHub account — not the account of whoever made this app.

"Show your GitHub profile next to your entries on the board." — the app's author

If you connect, this app will be able to:

  • Read your GitHub profile

Only this app gets access, and only while you allow it. You can stop this app, or disconnect GitHub altogether, from your account settings.

The buttons are Not now and Connect GitHub; the card names whichever provider you declared, and the bulleted list is the sentence for each scope you asked for. If your declaration lists no scopes, the card says so plainly rather than showing an empty list.

3. Read it from your backend

A connection is read as a particular person, so it has to run in a lane that knows who that person is. onChange is the natural one: it fires after a document write commits and acts as the user whose write triggered it.

Which account am I reading as?

Every backend lane runs as a resolved identity, and the connection it reaches is that person's:

  • onChange — the person whose write triggered it. This is the lane to use for anything on a visitor's behalf.
  • fetch — the signed-in caller of the _api request. An ordinary fetch("/_api/…") from inside your app carries no session, so it arrives anonymous and every connection call refuses (see the note below).
  • scheduled — the app's owner. A tick has no visitor, so a scheduled job reads the owner's own connected account and nobody else's.

The library: @vibes.diy/social

backend.js can import a small typed library for the three providers, so you never hand-write a query string or guess at a field name:

jsimport { instagram, connectionState } from "@vibes.diy/social";

export async function onChange(event, ctx) {
  const doc = event.doc;
  if (doc.type !== "feed-request" || doc.posts) return;

  const conn = await connectionState(ctx, "instagram");
  if (conn.state !== "live") return ctx.db.put({ ...doc, action: conn.state });

  const page = await instagram(ctx).media({ limit: 12 });
  return ctx.db.put({ ...doc, posts: page.items, cursor: page.cursor ?? null });
}

What it gives you:

  • connectionState(ctx, provider){ state: "live", expiresAt? }, { state: "needs_consent" }, { state: "dead" }, or { state: "error", reason }. It spends nothing, and it folds the four platform states and the refusal reasons into the three answers an app actually branches on: ask, tell them to reconnect, or go.
  • github(ctx).me(){ id, login, name, bio, avatarUrl, htmlUrl }.
  • threads(ctx).me(){ id, username }.
  • threads(ctx).posts({ limit, cursor }){ items, cursor? }, where each item is { id, text, permalink, timestamp, mediaType }, newest first.
  • instagram(ctx).me(){ id, username }.
  • instagram(ctx).media({ limit, cursor }){ items, cursor? }, where each item is { id, caption, mediaType, permalink, timestamp, likeCount, commentsCount }, newest first.
  • threads(ctx).publish({ text, imageUrl?, replyToId? }){ id }. Needs threads_content_publish. A text post needs text; an image post needs a publicly reachable URL the provider fetches itself.
  • instagram(ctx).publish({ imageUrl, caption? }){ id }. Needs instagram_business_content_publish. Instagram has no text-only post, so imageUrl is required.

Paging is the same everywhere: pass limit (1–100, default 25), read the cursor off the page you got, and pass it back as cursor for the next one. A page with no cursor is the last page — never treat a short page as the end.

likeCount and commentsCount are number | null, and null means the provider withheld the number (a post with likes hidden), never zero. Show nothing rather than a zero you invented.

A provider answering with an error status throws a ProviderResponseError carrying status, provider and path — that is the provider's own answer, not a refusal of your app.

Reading the status without spending anything

ctx.connections.status(provider) answers { state, consented, expiresAt?, accountLabel? } — the same four fields, from the same source, that useConnection holds in the page, so your backend and your UI can never disagree about whether someone is connected. It makes no call to the provider and costs nothing. connectionState is this call, folded; use whichever shape suits your code.

The raw primitive underneath

ctx.connections.fetch(provider, path, init) is what the library is built on. Reach for it when you want a path the library doesn't wrap yet:

jsconst res = await ctx.connections.fetch("github", "/user");
if (!res.ok) return ctx.db.put({ ...doc, error: "github said no" });
const me = await res.json();

It returns a real Response, so you read it exactly like any other fetch. The credential is attached on the platform side and never crosses into your code. Your app writes { type: "feed-request" }, watches that document with useLiveQuery, and renders whatever comes back — the same request-document pattern the rest of the backend guide uses.

Why not the fetch handler? Because an in-app call has no identity to spend. The _api lane resolves its caller only from an explicit Authorization bearer and deliberately ignores cookie sessions, so an ordinary fetch("/_api/…") from your app arrives anonymous and every connection call refuses with needs_consent — no matter how recently the visitor connected. The backend guide states the same rule generally: treat fetch callers as possibly anonymous, and move anything needing the visitor's identity to onChange.

The provider's own answer is not a refusal. A 404 or a 422 from GitHub is a successful proxy call and arrives as a Response with that status. Only two things become refusals: your call didn't pass a gate, or the provider said the credential itself is no good.

Branch on the reason, never on the provider's body

A refusal throws an Error carrying a reason from a closed set. That set — not the shape of a provider's error JSON — is what your code branches on. This is the whole contract: your app never learns the health of a connection by parsing somebody else's response format. (connectionState and classifyConnectionError from the library do this folding for you; the table is what they fold.)

reason What happened What to do
needs_consent This person hasn't allowed your app to use their connection — or allowed it for a narrower scope than this call needs. Ask: call link().
not_connected Consent is in place, but this person has no credential for the provider. Ask: call link().
connection_dead The credential was revoked or has expired. The grant is now marked dead for every future call. Ask them to reconnect.
provider_not_declared Your published config.connections doesn't name that provider. Declare it and push.
unknown_provider Not a provider the platform supports. Fix the call.
unmodeled_endpoint That path isn't on the proxy's allowlist. Use an allowed path.
scope_not_declared The path is allowed, but your release never declared the scope it's spent under. Declare the scope and push.
unsupported_method Anything other than GET. See below.
streaming_unsupported A streaming body, or a body that couldn't be encoded. Send a string.
body_too_large Over 1 MB. Send less.
upstream_error The call to the provider itself failed. Retry or degrade.

Two of these deliberately look alike from the outside. needs_consent is what an anonymous caller gets, what a signed-in stranger gets, and what someone who consented to a narrower scope gets — all the same answer, so a refusal never reveals whether a particular person has connected anything. And a provider your app never declared is refused before the platform will even say whether it exists, so this lane can't be used to enumerate what the platform supports.

Refusals carry a reason and nothing else — never the path you called, never the response body, never the credential. They end up in logs and screenshots, so they are built to be safe there.

Posting on someone's behalf

Your app can post to Threads and Instagram — and only if the visitor agreed to exactly that. Posting is a separate scope, it shows up on the consent card as its own sentence marked as changing their account, and the platform refuses the call otherwise. An app that declared only the read scopes and then tries to post is refused, and the fix is the declaration plus asking the person again — not a retry.

So the only thing standing between your app and someone's feed is a sentence they read and approved. Treat that as the promise it is: post on something they did, not on a timer while they are away, and tell them when a post fails rather than swallowing it.

What you can't do yet

GitHub is read-only on this lane. There is no GitHub write scope to declare, and that is a deliberate hold rather than an oversight: a connection is granted per person and per provider, so a consented GitHub write would reach every repository that person's credential reaches. "May read your profile" must not also mean "may rewrite any of your repositories", and the narrower grant that would make it safe — pinning writes to named repositories — isn't wired into this lane yet. If your app needs to write to GitHub today, it needs its own credential and its own integration.

The readable surface is small for the same reason:

  • GitHub — the visitor's own profile. Repository paths sit under a separate scope no app can declare yet.
  • Threads — the visitor's profile and their own posts.
  • Instagram — the visitor's profile and their own posts, with like and comment counts on each.

Posting a video isn't available yet, on either provider. Text and images work. A video takes time to process on the provider's side, and it hands back a placeholder before the video is ready — so posting one immediately would sometimes silently not land. The piece that would fix it, reading that placeholder's status, isn't wired up yet, and a post that works most of the time is worse than one that isn't offered.

Instagram views and plays aren't available. Those numbers live behind an insights permission no app can declare yet, so there is no way to read them — and no field on a post that quietly carries them. Build on likes and comments, or wait.

Expiry, revocation, and what stops

The platform watches credentials it holds and notifies the person before one dies — at fourteen, seven, three and one day out — so a working connection doesn't go dark without warning. Your app sees the same approach in state: anything expiring within two weeks reads as expiring rather than live.

People manage what they've connected in their own Settings, under Connected accounts — which lists every provider, connected or not, each with its own pair of buttons:

  • Stop this app withdraws one app's permission. Their account stays connected, every other app they allowed keeps working, and yours can ask again. In your backend this shows up on the very next call as needs_consent — there is no cache to wait out.
  • Disconnect drops that provider's credential itself, so every app they allowed stops at once. It withdraws those apps' permissions along with it, so reconnecting later doesn't quietly re-authorize everything they'd ever allowed — each app has to ask again. Your app's hook reads dead with consented: false — not absent: the platform keeps the revoked record rather than forgetting it, so you can tell "they disconnected" from "they were never connected" and word your prompt accordingly. Your backend gets needs_consent.

Both act on one provider at a time, so disconnecting Instagram leaves their GitHub connection exactly as it was.

Both are the platform's promise, and it is the same promise the rest of Vibes DIY makes about access:

Revoking stops your app receiving anything further. It does not reach back into data you already fetched and stored.

Anything you read through a connection and wrote into your app's database is your app's data from that moment on, and stays there until you delete it. If you keep a copy of someone's profile or their posts, treat it the way you'd treat any other record they gave you: hold what you need, delete what you don't, and don't assume revocation cleans up after you. The same rule for your app's own databases is in Local-first data and sync.

Where to go next