Connections — a visitor's own account, spent by your app
prompts/pkg/llms/connections.md on every deploy, so it
can't drift from what the platform actually does. Read it from your terminal any
time with npx vibes-diy skills.
A connection lets a visitor link their own third-party account (GitHub,
Threads, Instagram) to your app in one click, and lets your backend.js read
from that account — and, when they approve it, post to it — as them. The platform shows the consent screen, runs the
sign-in, holds the credential, refreshes it, and warns the person before it
expires. Your app contains no client id, secret, redirect route, token
field, or refresh code.
A visitor's account is never a pasted token, a ctx.secrets value, or a
document — the platform holds the credential and your code never sees it.
ctx.secrets is for the owner's own API keys (a weather API, a webhook
shared secret). Anything that belongs to the person using the app is a
connection. Never render an input that asks for an access token, and never
send anyone to a developer console to mint one — that is the flow this exists
to replace.
When to use it — signal words
"connect my Instagram", "sign in with GitHub", "my Threads posts", "show my
profile from …", "which of my videos got the most likes", "my followers on …".
A request that names one of the providers below and wants their data is a
connection. A request for public data nobody has to log in for (a public feed,
a public profile) is an ordinary ctx.fetch and needs no connection.
The three moving parts
Declare the provider in config.connections, render the Connect button from
useConnection, and read the account through @vibes.diy/social or
ctx.connections in backend.js. All three, every time — and the page
reaches the third with apiFetch (section 4), so it runs as the visitor.
1. Declare it — backend.js
backend.js
jsexport const config = {
connections: [
{
provider: "instagram",
scopes: ["instagram_business_basic"],
why: "Rank your recent posts by likes and comments.",
},
],
};
provider and scopes are checked when the app is pushed; an unknown value
refuses the push. why is required and is shown to the visitor verbatim on
the consent card, quoted as the app's own words — write it for them ("Show your
GitHub profile next to your entries."), never as a note to yourself.
| provider | scope | what it buys |
|---|---|---|
github |
read:user |
read the account's profile (github(ctx).me()) |
threads |
threads_basic |
read the account's profile and its own posts |
threads |
threads_content_publish |
post to their Threads feed (threads(ctx).publish()) |
instagram |
instagram_business_basic |
read the account's profile and its own media with like/comment counts |
instagram |
instagram_business_content_publish |
post a photo or video to their Instagram (instagram(ctx).publish()) |
A scope that posts is a separate scope, and asking for it is the whole
authorization. Declare it only when the app really posts, and say so in why
— the visitor sees "Post to your Threads feed as you" beside your sentence and
decides. An app that declared only the read scopes cannot post: the platform
refuses the call, not because posting is forbidden but because nobody agreed to
it.
GitHub is read-only on this lane — there is no GitHub write scope to
declare. Instagram views and plays (the insights edge) are not available
either. Build the app around what the table gives you; do not add a token field
for the rest.
2. Ask for it — App.jsx
App.jsx
jsximport { useConnection } from "use-vibes";
import { Button, Text } from "@vibes.diy/pouf";
function ConnectInstagram() {
const { state, consented, available, accountLabel, link, refresh } = useConnection("instagram");
if (state === "pending") return null; // still resolving — never flash a Connect button
// A connection that already works keeps working, whatever `available` says.
if (consented && (state === "live" || state === "expiring"))
return <Text>Connected as {accountLabel}</Text>;
// Everything below asks the visitor to start or redo a connection, which is
// the only thing `available` can stop.
if (!available) return <Text muted>Connecting Instagram is not available right now.</Text>;
if (state === "dead") return <Text muted>Your Instagram connection expired. Reconnect it in Settings.</Text>;
return <Button type="button" onClick={link}>Connect Instagram</Button>;
}
useConnection(provider) returns state (pending | absent | live |
expiring | dead), consented (whether this app has been approved — a
live account with consented: false is a one-click ask, not a full
sign-in), available, accountLabel and expiresAt for display, link() to
open the platform's consent card, and refresh() to re-read.
available is about the platform, not the person: it is false when we have not
finished setting that provider up, or when it is switched off for a while. It
is not the visitor's fault and it is not permanent.
It stops a connection being started, never one that already works. A
visitor who connected earlier keeps their connection and your backend keeps
spending it, so render the connected branch first and let available gate only
the branches that ask somebody to connect or reconnect. Getting that order
wrong takes a working account away from a visitor who still has one. Where it
does apply: hide or disable the Connect button, say plainly that connecting is
not available right now, and let the rest of the app keep working. Never leave
a Connect button standing over it — pressing it asks the visitor to approve
something that then fails. link() resolves when the
flow starts, not when it finishes — poll refresh() a few times while the
surface is visible if you want to react to completion. Disconnecting lives in
the platform's Settings page, not in your app.
3. Read it — backend.js
backend.js
jsimport { instagram, connectionState } from "@vibes.diy/social";
export async function fetch(request, ctx) {
const url = new URL(request.url);
if (url.pathname.endsWith("/top-posts")) {
const conn = await connectionState(ctx, "instagram");
if (conn.state !== "live") return Response.json({ connection: conn.state }, { status: 409 });
const ig = instagram(ctx);
const page = await ig.media({ limit: 50 });
const ranked = [...page.items].sort((a, b) => (b.likeCount ?? 0) - (a.likeCount ?? 0));
return Response.json({ posts: ranked.slice(0, 10) });
}
return new Response("not found", { status: 404 });
}
@vibes.diy/social is the only import a backend.js can make. It wraps every
provider call as a typed request with a typed response, so there is no
fields= string to get wrong and no JSON key to guess:
connectionState(ctx, provider)→{ state: "live" | "needs_consent" | "dead" | "error" }. Spends nothing.needs_consentmeans render the Connect button;deadmeans tell the person to reconnect in Settings;errorcarries areason.github(ctx).me()→{ id, login, name, bio, avatarUrl, htmlUrl }.threads(ctx).me()→{ id, username };threads(ctx).posts({ limit, cursor })→{ items: [{ id, text, permalink, timestamp, mediaType }], cursor? }.instagram(ctx).me()→{ id, username };instagram(ctx).media({ limit, cursor })→{ items: [{ id, caption, mediaType, permalink, timestamp, likeCount, commentsCount }], cursor? }(likeCountisnullwhen the person hides likes, never0).threads(ctx).publish({ text, imageUrl?, replyToId? })→{ id }. Needsthreads_content_publish. A text post needstext; an image post needs a publicly reachableimageUrlthe provider fetches itself.instagram(ctx).publish({ imageUrl, caption? })→{ id }. Needsinstagram_business_content_publish. Instagram has no text-only post, soimageUrlis required.
Video posting is not available on either provider yet — the provider hands
back a container while the video is still processing, and the platform cannot
yet read that container's status, so a video post would intermittently fail
rather than land. Build on text and images; do not work around it with a raw
ctx.connections call, which hits the same race.
Posting on someone's behalf is a real act, so let the person press the
button — post from a fetch route the app calls on the person's own action, and
never from a scheduled tick that posts for a visitor who is not there. A post
that fails is worth telling them about, so surface the thrown error rather than
swallowing it.
Page with cursor until it is absent. A provider error surfaces as a thrown
ProviderResponseError with status; a connection problem surfaces as a
thrown error with reason (needs_consent, connection_dead,
provider_not_declared, …) — branch on reason, never on the message text.
Underneath the library sit the raw primitives, for a path the library does not
wrap: await ctx.connections.fetch(provider, path) returns a normal Response
with the credential attached by the platform, and
await ctx.connections.status(provider) returns
{ state, consented, expiresAt?, accountLabel? } without spending a call.
Whose account? In a fetch handler it is the signed-in visitor the page
called the route for with apiFetch (section 4); in scheduled it is the app
owner's. A scheduled tick can only ever read the owner's connection — an app
that ranks each visitor's posts does that work in fetch, when they are there.
4. Call it from the page — apiFetch
The page calls its connection-backed routes with apiFetch from use-vibes, so the
backend runs as the visitor who connected. apiFetch asks the platform to make
the call for the signed-in visitor: the handler's ctx.userInfo is that
visitor, and ctx.connections and @vibes.diy/social spend their
connection. The path is the route under /_api, so apiFetch("/top-posts")
reaches the /top-posts branch above. Wait for the connection before calling,
so the first answer is the real one.
App.jsx
jsximport React, { useEffect, useState } from "react";
import { useConnection, apiFetch } from "use-vibes";
function TopPosts() {
const { state, consented } = useConnection("instagram");
const connected = consented && (state === "live" || state === "expiring");
const [posts, setPosts] = useState([]);
const [problem, setProblem] = useState(null);
useEffect(() => {
if (!connected) return;
apiFetch("/top-posts")
.then(async (res) => {
const data = await res.json();
if (res.ok) setPosts(data.posts);
else setProblem(`Instagram is ${data.connection ?? "not ready"} — try again in a moment.`);
})
.catch(() => setProblem("Could not reach the app just now."));
}, [connected]);
if (!connected) return null;
if (problem) return <p>{problem}</p>;
return (
<ol>
{posts.map((p) => (
<li key={p.id}>
<a href={p.permalink}>{p.caption}</a> — {p.likeCount ?? "hidden"} likes
</li>
))}
</ol>
);
}
apiFetch(path, { method, headers, body }) answers like fetch: status,
ok, headers, text() and json(). The route's own status comes back as
an answer — a 409 with { connection: "needs_consent" } resolves with
ok: false — and the promise rejects only when the route could not be reached.
Send JSON as body: JSON.stringify(data) with a content-type: application/json header. A visitor who is not signed in still reaches the
route, with ctx.userInfo set to null. The platform keeps the visitor's
sign-in to itself: apiFetch carries who they are, and your code never handles
a token.
Ticks that have nothing to do
A scheduled handler that finds nothing to do says so with ctx.log, so an
idle tick is never silent. A tick that ranks posts every 15 minutes but has
no connection yet should log "instagram not connected, nothing to rank" and
return — 155 silent ticks look identical to 155 working ones, and nobody can
tell the app is waiting.
What not to build
- No token, API key, password or "paste your login" input, for any provider.
- No
ctx.secrets.INSTAGRAM_TOKEN-style keys for a visitor's account. - No OAuth redirect route, no
client_id, norefresh_access_tokenloop — the platform owns all of it. - No
Authorization: Bearer …header built in app code;ctx.connectionsattaches the credential. - No posting a person did not ask for: declare a publish scope only when the app posts, and post on an action they took, never on a timer behind their back.
- No pretending an app can post without declaring it — a
POSTunder a read-only declaration is refused, and the fix is the declaration plus a re-approval, not a retry.