Vibes DIY
Vibes DIY / Docs / Reference
creator docs · reference · generated

Connections — a visitor's own account, spent by your app

This is the same document the Vibes code generator reads when it builds apps — rendered from 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_consent means render the Connect button; dead means tell the person to reconnect in Settings; error carries a reason.
  • 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? } (likeCount is null when the person hides likes, never 0).
  • threads(ctx).publish({ text, imageUrl?, replyToId? }) → { id }. Needs threads_content_publish. A text post needs text; an image post needs a publicly reachable imageUrl 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.

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, no refresh_access_token loop — the platform owns all of it.
  • No Authorization: Bearer … header built in app code; ctx.connections attaches 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 POST under a read-only declaration is refused, and the fix is the declaration plus a re-approval, not a retry.