Skip to content
Connect an agent

Build

Add account connecting to your site

Put a panel on your own page where your users connect, reconnect and disconnect their social accounts, without your API key ever reaching the browser.

TL;DR

QuestionAnswer
What are the parts?A route on your server that makes a connect token, and <flung-connect> on the page, which uses it.
What does a person see?The platforms you offer and their connected accounts, with Connect, Reconnect and Disconnect. Connecting opens the platform's own sign-in in a small window.
Which frameworks?Any. Next.js, React with Vite and a plain page are shown below, each from a working example.
Can each of my users have their own accounts?Yes: make each token with that user's profileId.

Install

bash
npm install @flung.dev/sdk @flung.dev/connect

Next.js

The server keeps the key, in .env.local:

.env.local

bash
# A full-access Flung API key (flung_sk_...). Server only: never prefix it with NEXT_PUBLIC_.FLUNG_API_KEY=
# Optional. The API: https://api.flung.dev (the default) or https://api-preview.flung.dev.FLUNG_BASE_URL=
# The origin this app is served from, which connect tokens are made for. http://localhost:3000 when left out.APP_ORIGIN=

A route makes a connect token for the page. Put your own sign-in check at its top:

app/api/connect-token/route.ts

ts
import { Flung, FlungError } from '@flung.dev/sdk'
/** * `POST /api/connect-token`: a short-lived connect token for this app's own page. The API key stays here on the server; * the token it makes can only list, connect, reconnect and disconnect accounts, from `APP_ORIGIN`, for fifteen minutes. * * In a real app, check who is signed in first, and pass their profile as `profileId` so each person sees only theirs. */export async function POST(): Promise<Response> {  const apiKey = process.env.FLUNG_API_KEY  if (apiKey === undefined || apiKey === '') return Response.json({ error: 'Set FLUNG_API_KEY in .env.local.' }, { status: 500 })  const flung = new Flung({ apiKey, ...(process.env.FLUNG_BASE_URL ? { baseUrl: process.env.FLUNG_BASE_URL } : {}) })  try {    const { token, expiresAt } = await flung.connectTokens.create({ origins: [process.env.APP_ORIGIN || 'http://localhost:3000'], expiresIn: 900 })    return Response.json({ token, expiresAt }, { headers: { 'cache-control': 'no-store' } })  } catch (error) {    const status = error instanceof FlungError ? (error.status ?? 502) : 502    return Response.json({ error: error instanceof Error ? error.message : 'Could not make a connect token.' }, { status })  }}

The page shows the panel, which asks the route for a token, and again when one expires:

app/accounts.tsx

tsx
'use client'
import { useCallback, useState } from 'react'
import { FlungConnect } from '@flung.dev/connect/react'
/** Asks this app's server for a connect token; the panel calls it again when a token expires. */async function connectToken(): Promise<string> {  const response = await fetch('/api/connect-token', { method: 'POST' })  const body = (await response.json()) as { token?: string; error?: string }  if (!response.ok || body.token === undefined) throw new Error(body.error ?? 'Could not make a connect token.')  return body.token}
export function Accounts({ baseUrl }: { baseUrl: string | undefined }) {  const [events, setEvents] = useState<string[]>([])  const log = useCallback((line: string) => setEvents((previous) => [line, ...previous].slice(0, 5)), [])  return (    <>      <FlungConnect        token={connectToken}        baseUrl={baseUrl}        onConnect={({ accounts }) => log(`Connected ${accounts.map((account) => account.username ?? account.id).join(', ')}`)}        onDisconnect={({ account }) => log(`Disconnected ${account.username ?? account.id}`)}        onError={({ code, message }) => log(`${code}: ${message}`)}      />      {events.length > 0 && (        <ul className="events" aria-label="What happened">          {events.map((line, index) => (            <li key={`${index}-${line}`}>{line}</li>          ))}        </ul>      )}    </>  )}

React with Vite

For trying the panel out, Vite's development server can make the token itself:

vite.config.ts

ts
import { Flung } from '@flung.dev/sdk'import { defineConfig, loadEnv } from 'vite'import type { Connect, Plugin } from 'vite'
/** * `POST /connect-token` on the dev and preview servers: makes a connect token for the page with the key from * `.env.local`. The key has no `VITE_` prefix, so Vite never puts it in the bundle. This is for trying the panel out; a * deployed app makes tokens on its own server, behind its own sign-in. */function connectTokens(apiKey: string, baseUrl: string | undefined): Plugin {  const handler: Connect.NextHandleFunction = (request, response, next) => {    if (request.method !== 'POST' || request.url !== '/connect-token') return next()    const send = (status: number, body: unknown) => {      response.statusCode = status      response.setHeader('content-type', 'application/json')      response.setHeader('cache-control', 'no-store')      response.end(JSON.stringify(body))    }    if (apiKey === '') return send(500, { error: 'Set FLUNG_API_KEY in .env.local.' })    const origin = request.headers.origin ?? `http://${request.headers.host ?? 'localhost:5173'}`    const flung = new Flung({ apiKey, ...(baseUrl ? { baseUrl } : {}) })    flung.connectTokens.create({ origins: [origin], expiresIn: 900 }).then(      ({ token }) => send(200, { token }),      (error: unknown) => send(502, { error: error instanceof Error ? error.message : 'Could not make a connect token.' }),    )  }  return { name: 'flung-connect-tokens', configureServer: (server) => void server.middlewares.use(handler), configurePreviewServer: (server) => void server.middlewares.use(handler) }}
export default defineConfig(({ mode }) => {  const env = loadEnv(mode, process.cwd(), '')  return {    plugins: [connectTokens(env.FLUNG_API_KEY ?? '', env.FLUNG_BASE_URL || undefined)],    define: { 'import.meta.env.FLUNG_BASE_URL': JSON.stringify(env.FLUNG_BASE_URL || '') },    esbuild: { jsx: 'automatic' },  }})

src/App.tsx

tsx
import { useCallback, useState } from 'react'
import { FlungConnect } from '@flung.dev/connect/react'
/** Asks the dev server for a connect token; the panel calls it again when a token expires. */async function connectToken(): Promise<string> {  const response = await fetch('/connect-token', { method: 'POST' })  const body = (await response.json()) as { token?: string; error?: string }  if (!response.ok || body.token === undefined) throw new Error(body.error ?? 'Could not make a connect token.')  return body.token}
const baseUrl = import.meta.env.FLUNG_BASE_URL || undefined
export function App() {  const [events, setEvents] = useState<string[]>([])  const log = useCallback((line: string) => setEvents((previous) => [line, ...previous].slice(0, 5)), [])  return (    <main>      <h1>Connect your accounts</h1>      <p>We post to the accounts you connect here. You can disconnect them at any time.</p>      <FlungConnect        token={connectToken}        baseUrl={baseUrl}        onConnect={({ accounts }) => log(`Connected ${accounts.map((account) => account.username ?? account.id).join(', ')}`)}        onDisconnect={({ account }) => log(`Disconnected ${account.username ?? account.id}`)}        onError={({ code, message }) => log(`${code}: ${message}`)}      />      {events.length > 0 && (        <ul className="events" aria-label="What happened">          {events.map((line, index) => (            <li key={`${index}-${line}`}>{line}</li>          ))}        </ul>      )}    </main>  )}

A deployed app makes tokens on its own server, behind its own sign-in, as the Next.js route does.

A plain page

No framework and nothing to install: a server of a few lines makes the token, and the page loads the panel.

server.mjs

js
#!/usr/bin/env node/** * The smallest server a connect panel needs, with nothing to install: it keeps the API key, makes a connect token for * the page when the page asks, and serves the page and the built package. * *   pnpm build *   FLUNG_API_KEY=flung_sk_... node examples/vanilla/server.mjs * * Then open http://localhost:5173. FLUNG_BASE_URL=https://api-preview.flung.dev tries it against preview. */import { readFile } from 'node:fs/promises'import { createServer } from 'node:http'import { extname, join, normalize } from 'node:path'
const PORT = Number(process.env.PORT ?? 5173)const ORIGIN = `http://localhost:${PORT}`const BASE_URL = process.env.FLUNG_BASE_URL ?? 'https://api.flung.dev'const API_KEY = process.env.FLUNG_API_KEYif (API_KEY === undefined) throw new Error('Set FLUNG_API_KEY to a full-access Flung API key.')
const here = import.meta.dirnameconst dist = join(here, '..', '..', 'packages', 'connect', 'dist')const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8' }
/** In your app, check who is signed in here first, and pass their profile as `profileId`. */async function connectToken() {  const response = await fetch(`${BASE_URL}/v1/connect-tokens`, {    method: 'POST',    headers: { authorization: `Bearer ${API_KEY}`, 'content-type': 'application/json' },    body: JSON.stringify({ origins: [ORIGIN], expiresIn: 900 }),  })  return { status: response.status, body: await response.text() }}
createServer(async (request, response) => {  const path = new URL(request.url ?? '/', ORIGIN).pathname  if (request.method === 'POST' && path === '/connect-token') {    const made = await connectToken()    response.writeHead(made.status, { 'content-type': 'application/json' }).end(made.body)    return  }  const file = path === '/' ? join(here, 'index.html') : path.startsWith('/connect/') ? normalize(join(dist, path.slice('/connect/'.length))) : null  if (file === null || (file !== join(here, 'index.html') && !file.startsWith(dist))) {    response.writeHead(404).end()    return  }  try {    const page = file.endsWith('index.html') ? (await readFile(file, 'utf8')).replace('__FLUNG_BASE_URL__', BASE_URL) : await readFile(file)    response.writeHead(200, { 'content-type': TYPES[extname(file)] ?? 'application/octet-stream' }).end(page)  } catch {    response.writeHead(404).end()  }}).listen(PORT, () => process.stdout.write(`Open ${ORIGIN}\n`))

index.html

html
<!doctype html><html lang="en">  <head>    <meta charset="utf-8">    <meta name="viewport" content="width=device-width, initial-scale=1">    <title>Connect your accounts</title>    <style>      body { font: 16px/1.5 system-ui, sans-serif; max-width: 36rem; margin: 8vh auto; padding: 0 16px; }      @media (prefers-color-scheme: dark) { body { background: #09090b; color: #f4f4f5; } }      pre { font-size: 13px; overflow: auto; }    </style>  </head>  <body>    <h1>Connect your accounts</h1>    <p>Flung posts to the accounts you connect here. You can disconnect them at any time.</p>    <flung-connect id="panel" base-url="__FLUNG_BASE_URL__"></flung-connect>    <pre id="log"></pre>    <script type="module">      import '/connect/register.js'
      const panel = document.getElementById('panel')      const log = (type, detail) => (document.getElementById('log').textContent += `${type} ${JSON.stringify(detail)}\n`)      // A function, so the panel asks for a fresh token when one expires.      panel.token = async () => {        const response = await fetch('/connect-token', { method: 'POST' })        if (!response.ok) throw new Error(`Could not make a connect token: ${await response.text()}`)        return (await response.json()).token      }      for (const type of ['flung-connect', 'flung-disconnect', 'flung-error']) panel.addEventListener(type, (event) => log(type, event.detail))    </script>  </body></html>

How it behaves

  • The panel offers only the platforms this Flung can connect, narrowed by its platforms attribute and the token's own list.
  • Connecting opens the platform's sign-in in a small window. When a browser blocks the window, the panel shows a link instead.
  • Mastodon asks for the person's server first; Bluesky takes a handle when they have one.
  • Disconnecting asks to confirm. The account then leaves the panel.
  • With mode="redirect" the whole page goes to the platform and back, for pages that cannot open windows.

Every attribute, event, style hook and word is in the connect panel reference.