Documentation

Byoky Docs

Star

Everything you need to integrate Byoky into your app — from quickstart to API reference.

Building with an AI assistant?
Copy the setup prompt, paste into Claude, ChatGPT, or Cursor, and start building with a Byoky-aware model.

Overview

Byoky lets users store their AI API keys in an encrypted wallet. Your app never sees the keys — it gets a proxied session that routes requests through the wallet.

How it works

Your App → SDK (createFetch) → Content Script → Extension → LLM API
                                                    ↑
                                          Keys stay here. Always.

Two lines changed. Full API compatibility. Streaming, file uploads, and vision all work. Sessions auto-reconnect if the extension restarts.

Installation

Install the SDK

bash
npm install @byoky/sdk

Scaffold a new project

bash
npx create-byoky-app my-app

# Choose a template:
#   1. AI Chat (Next.js)
#   2. Multi-Provider (Vite)
#   3. Backend Relay (Express)

User wallets

Your users need one of these installed:

Quickstart

Connect and make your first request in under a minute:

import Anthropic from '@anthropic-ai/sdk';
import { Byoky } from '@byoky/sdk';

const byoky = new Byoky();
const session = await byoky.connect({
  providers: [{ id: 'anthropic', required: true }],
  modal: true,  // shows built-in connect UI with QR code
});

// Use the native Anthropic SDK — just swap in Byoky's fetch
const client = new Anthropic({
  apiKey: session.sessionKey,
  fetch: session.createFetch('anthropic'),
});

const message = await client.messages.create({
  model: 'claude-sonnet-4-20250514',
  max_tokens: 1024,
  messages: [{ role: 'user', content: 'Hello!' }],
});

That's it. Full API compatibility — streaming, file uploads, and vision all work unchanged.

Dev Sandbox

For local development and CI, the SDK can run without a wallet at all. Pass your own keys via byoky.connectMock() and the returned session behaves exactly like a real one — same createFetch, same provider IDs — except requests go straight to the upstream provider.

This is a development convenience, not a security boundary. The SDK refuses to construct a mock session when NODE_ENV=production. Never ship code that relies on it.

From an environment variable

Set BYOKY_DEV_KEYS to a comma-separated list of provider:key pairs. Works in Node.js (CI, scripts, server-side tests):

bash
# .env.local or shell
BYOKY_DEV_KEYS=anthropic:sk-ant-...,openai:sk-...
typescript
import { Byoky } from '@byoky/sdk';

const session = await new Byoky().connectMock();

// Use the session exactly like a real one
import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic({
  apiKey: session.sessionKey,
  fetch: session.createFetch('anthropic'),
});

With explicit keys

For unit tests, browser dev, or cases where the env var isn't available:

typescript
const session = await new Byoky().connectMock({
  keys: {
    anthropic: process.env.ANTHROPIC_API_KEY!,
    openai:    process.env.OPENAI_API_KEY!,
  },
});

Local providers (Ollama, LM Studio, Azure)

Providers without a fixed upstream host accept a per-provider baseUrl:

typescript
const session = await new Byoky().connectMock({
  keys: { ollama: 'ollama' }, // local servers usually accept any value
  baseUrls: {
    ollama: 'http://localhost:11434',
  },
});

Caveats

  • session.createRelay() throws — relays go through a real wallet.
  • session.getUsage() always returns zeros — no metering happens.
  • Cross-provider routing, gift redemption, and group rebinding are not simulated.
  • The auth header convention is fixed per provider (x-api-key for Anthropic, query ?key= for Gemini, Authorization: Bearer for everyone else).

Byoky Client

Constructor

typescript
import { Byoky } from '@byoky/sdk';

const byoky = new Byoky({
  timeout: 60000,                      // connection timeout (ms)
  relayUrl: 'wss://relay.byoky.com',   // relay server for mobile pairing
});

byoky.connect(options)

Connect to a Byoky wallet. Returns a ByokySession.

const session = await byoky.connect({
  // Which providers your app needs
  providers: [
    { id: 'anthropic', required: true },
    { id: 'openai', required: false },
  ],

  // Show built-in modal with extension detection + QR code fallback
  modal: true,

  // Or handle pairing yourself
  onPairingReady: (code) => showQR(code),

  // Skip extension, go straight to relay (mobile)
  useRelay: true,
});
providersProviderRequirement[]
List of providers your app needs. required: true means connection fails if the user doesn't have that provider.
modalboolean | ModalOptions
Show the built-in connect modal. Handles extension detection, relay fallback, and QR code for mobile pairing automatically.
onPairingReady(code: string) => void
Called with a pairing code when no extension is detected. Display as QR or text for mobile wallet pairing.
useRelayboolean
Skip extension detection and go directly to relay pairing.

byoky.tryReconnect()

Silently reconnect to an existing session. Checks persisted vault sessions, extension live sessions, and stored extension sessions in order. Returns null if nothing is restorable.

const session = await byoky.tryReconnect();
if (session) {
  // Restored — ready to make requests
}

byoky.connectViaVault(options)

Connect via a Byoky Vault server. Works in both browser and Node.js environments.

typescript
const session = await byoky.connectViaVault({
  vaultUrl: 'https://vault.byoky.com',
  username: 'user@example.com',
  password: 'password',
  providers: [{ id: 'anthropic' }],
  appOrigin: 'https://myapp.com', // required in Node.js
});

Utilities

typescript
import { isExtensionInstalled, getStoreUrl } from '@byoky/sdk';

// Check if the Byoky extension is installed
if (isExtensionInstalled()) { ... }

// Get the store URL for the user's browser
const url = getStoreUrl(); // Chrome Web Store, Firefox Add-ons, etc.

Session API

A ByokySession is returned by connect(), tryReconnect(), or connectViaVault(). It provides everything you need to make API calls through the wallet.

session.createFetch(providerId)

Returns a fetch function that proxies requests through the wallet for the given provider. Use it as a drop-in replacement with any provider SDK.

// Anthropic
const client = new Anthropic({
  apiKey: session.sessionKey,
  fetch: session.createFetch('anthropic'),
});

// OpenAI
const client = new OpenAI({
  apiKey: session.sessionKey,
  fetch: session.createFetch('openai'),
});

// Or raw fetch
const fetch = session.createFetch('anthropic');
const res = await fetch('https://api.anthropic.com/v1/messages', {
  method: 'POST',
  headers: { 'content-type': 'application/json', 'anthropic-version': '2023-06-01' },
  body: JSON.stringify({ model: 'claude-sonnet-4-20250514', max_tokens: 1024, messages: [...] }),
});

session.createRelay(wsUrl)

Open a WebSocket relay channel so a backend server can make LLM calls through this session. See Backend Relay.

session.disconnect()

Disconnect the session. The wallet revokes all access.

session.isConnected()

Returns true if the session is still valid.

session.getUsage()

Get token usage stats for this session.

interface SessionUsage {
  requests: number;
  inputTokens: number;
  outputTokens: number;
  byProvider: Record<string, {
    requests: number;
    inputTokens: number;
    outputTokens: number;
  }>;
}

const usage = await session.getUsage();
// { requests: 42, inputTokens: 15000, outputTokens: 8000,
//   byProvider: { anthropic: { requests: 42, inputTokens: 15000, outputTokens: 8000 } } }

session.onDisconnect(callback)

Register a callback for when the user revokes this session from the wallet.

session.onProvidersUpdated(callback)

Register a callback for when provider availability changes — e.g. the user adds a credential, revokes one, or swaps the provider group bound to your app (cross-provider routing). The callback receives the new session.providers record.

Session properties

typescript
session.sessionKey  // string — use as apiKey in provider SDKs
session.proxyUrl    // string — the proxy endpoint URL
session.providers   // Record<ProviderId, ProviderStatus>

interface ProviderStatus {
  // true: the wallet has a working credential (or gift) for this provider
  //       and will hit the provider directly.
  // false: your app can still call createFetch(id) — the wallet may route
  //        it through another provider via cross-provider translation.
  available: boolean;

  // How the credential authenticates upstream.
  authMethod: 'api_key' | 'oauth';

  // Present and true when the credential came from a redeemed Token Gift.
  // The gifter's wallet proxies every request and enforces the token budget.
  gift?: boolean;
}

Check providers[id].available before assuming direct access. A provider marked available: false may still work if the user has set up cross-provider routing. See Cross-Provider Routing.

Providers

All providers work with createFetch(providerId):

anthropicAnthropic (Claude)
openaiOpenAI (GPT)
geminiGoogle Gemini
mistralMistral
cohereCohere
xaixAI (Grok)
deepseekDeepSeek
perplexityPerplexity
groqGroq
togetherTogether AI
fireworksFireworks AI
openrouterOpenRouter
azure_openaiAzure OpenAI
ollamaOllama (local)
lm_studioLM Studio (local)

Wire-format families

Providers fall into one of four wire-format families. Two providers in the same family speak the same API surface and are byte-for-byte interchangeable; crossing families requires the wallet's translation layer (see Cross-Provider Routing).

anthropicanthropic
openaiopenai, mistral, xai, deepseek, perplexity, groq, together, fireworks, openrouter, azure_openai, ollama, lm_studio
geminigemini
coherecohere

Capability matrix

What each family supports natively at the wire-format level. Capabilities below this baseline depend on the specific model — e.g. gpt-5.4-nano drops vision, Cohere's Command A drops vision and JSON mode. Always check the provider's docs for the exact model you target.

FamilyStreamingTool useVisionJSON modeReasoning
anthropicvia tool use
openai(json_schema, json_object)
gemini(responseMimeType)
cohere

Local providers

ollama and lm_studio point at a user-run loopback server (default http://localhost:11434 for Ollama, http://localhost:1234 for LM Studio). The wallet stores the per-credential base URL alongside the API key — your app code doesn't need to know which port. Both expose an OpenAI-compatible chat surface, so they belong to the openai wire-format family.

Streaming

Every provider's streaming format works unchanged through createFetch. The proxy forwards response chunks over a persistent port — no buffering, no polling, no special flags on your end.

With a provider SDK

The easiest path — the SDK handles SSE parsing for you:

typescript
import Anthropic from '@anthropic-ai/sdk';

const client = new Anthropic({
  apiKey: session.sessionKey,
  fetch: session.createFetch('anthropic'),
});

const stream = client.messages.stream({
  model: 'claude-sonnet-4-20250514',
  max_tokens: 1024,
  messages: [{ role: 'user', content: 'Write a haiku.' }],
});

for await (const event of stream) {
  if (event.type === 'content_block_delta'
    && event.delta.type === 'text_delta') {
    process.stdout.write(event.delta.text);
  }
}

With raw fetch

If you prefer to call the HTTP API directly, parse SSE from the returned response.body:

typescript
const fetch = session.createFetch('anthropic');
const res = await fetch('https://api.anthropic.com/v1/messages', {
  method: 'POST',
  headers: {
    'content-type': 'application/json',
    'anthropic-version': '2023-06-01',
  },
  body: JSON.stringify({
    model: 'claude-sonnet-4-20250514',
    max_tokens: 1024,
    stream: true,
    messages: [{ role: 'user', content: 'Hello!' }],
  }),
});

const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  buf += decoder.decode(value, { stream: true });
  const lines = buf.split('\n');
  buf = lines.pop() || '';
  for (const line of lines) {
    if (!line.startsWith('data: ')) continue;
    const data = line.slice(6);
    if (data === '[DONE]') return;
    const event = JSON.parse(data);
    if (event.type === 'content_block_delta') {
      process.stdout.write(event.delta.text);
    }
  }
}

OpenAI-compatible providers (OpenAI, Groq, DeepSeek, xAI, Mistral, Together, Fireworks, Perplexity, OpenRouter) stream choices[0].delta.content in the same SSE envelope. Gemini uses streamGenerateContent.

Tool Use

Tool use (a.k.a. function calling) works unchanged through the proxy. Define tools, let the model call them, execute locally, feed results back — loop until the model stops asking for tools.

Anthropic format

typescript
const fetch = session.createFetch('anthropic');
const tools = [{
  name: 'get_weather',
  description: 'Get current weather for a city',
  input_schema: {
    type: 'object',
    properties: { city: { type: 'string' } },
    required: ['city'],
  },
}];

const messages: Array<Record<string, unknown>> = [
  { role: 'user', content: "What's the weather in Tokyo?" },
];

for (let round = 0; round < 5; round++) {
  const res = await fetch('https://api.anthropic.com/v1/messages', {
    method: 'POST',
    headers: {
      'content-type': 'application/json',
      'anthropic-version': '2023-06-01',
    },
    body: JSON.stringify({
      model: 'claude-sonnet-4-20250514',
      max_tokens: 1024,
      tools,
      messages,
    }),
  });
  const data = await res.json();
  const toolCalls = data.content.filter((b: any) => b.type === 'tool_use');
  if (toolCalls.length === 0) {
    console.log(data.content.find((b: any) => b.type === 'text')?.text);
    break;
  }
  const results = toolCalls.map((tc: any) => ({
    type: 'tool_result',
    tool_use_id: tc.id,
    content: JSON.stringify(runTool(tc.name, tc.input)),
  }));
  messages.push({ role: 'assistant', content: data.content });
  messages.push({ role: 'user', content: results });
}

OpenAI-compatible format

Used by OpenAI, Groq, DeepSeek, xAI, Mistral, Together, Fireworks, Perplexity, and OpenRouter. Tools are wrapped in { type: 'function', function: { ... } }, and the model returns choices[0].message.tool_calls:

typescript
const fetch = session.createFetch('openai');
const tools = [{
  type: 'function',
  function: {
    name: 'get_weather',
    description: 'Get current weather for a city',
    parameters: {
      type: 'object',
      properties: { city: { type: 'string' } },
      required: ['city'],
    },
  },
}];

const messages: Array<Record<string, unknown>> = [
  { role: 'user', content: "What's the weather in Tokyo?" },
];

for (let round = 0; round < 5; round++) {
  const res = await fetch('https://api.openai.com/v1/chat/completions', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ model: 'gpt-4o', tools, messages }),
  });
  const data = await res.json();
  const msg = data.choices[0].message;
  if (!msg.tool_calls?.length) { console.log(msg.content); break; }
  messages.push(msg);
  for (const tc of msg.tool_calls) {
    const args = JSON.parse(tc.function.arguments);
    messages.push({
      role: 'tool',
      tool_call_id: tc.id,
      content: JSON.stringify(runTool(tc.function.name, args)),
    });
  }
}

Structured Output

Get typed JSON back from any OpenAI-compatible provider, plus Anthropic. Two modes exist: OpenAI's strict json_schema (enforced by the model), and the looser json_object mode supported by most OpenAI-compatible providers.

OpenAI strict schema

typescript
const fetch = session.createFetch('openai');
const res = await fetch('https://api.openai.com/v1/chat/completions', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({
    model: 'gpt-4o',
    messages: [{ role: 'user', content: 'Extract: "Jane, jane@acme.co, Acme"' }],
    response_format: {
      type: 'json_schema',
      json_schema: {
        name: 'contact',
        strict: true,
        schema: {
          type: 'object',
          properties: {
            name:    { type: 'string' },
            email:   { type: 'string' },
            company: { type: 'string' },
          },
          required: ['name', 'email', 'company'],
          additionalProperties: false,
        },
      },
    },
  }),
});

const data = await res.json();
const contact = JSON.parse(data.choices[0].message.content);

json_object (Groq, DeepSeek, Mistral, Together, Fireworks, OpenRouter, xAI)

typescript
body: JSON.stringify({
  model: 'llama-3.3-70b-versatile',
  messages: [{ role: 'user', content: 'Return JSON with keys name, email.' }],
  response_format: { type: 'json_object' },
});

Anthropic

Claude doesn't have a response_format field. Prompt it to return JSON and parse the text block — or use tool use with a single tool as the forced schema:

typescript
const res = await fetch('https://api.anthropic.com/v1/messages', {
  method: 'POST',
  headers: {
    'content-type': 'application/json',
    'anthropic-version': '2023-06-01',
  },
  body: JSON.stringify({
    model: 'claude-sonnet-4-20250514',
    max_tokens: 1024,
    messages: [{
      role: 'user',
      content: 'Return ONLY JSON: { "name": "...", "email": "..." } for: "Jane, jane@acme.co"',
    }],
  }),
});
const data = await res.json();
const json = JSON.parse(data.content[0].text.match(/\{[\s\S]*\}/)![0]);

Vision

Image inputs work through the proxy just like text. Anthropic, OpenAI, and Gemini each take a different wire format — the payload pattern below matches what ships in the demo.

Convert a File to base64

typescript
async function fileToBase64(file: File): Promise<string> {
  const buffer = await file.arrayBuffer();
  const bytes = new Uint8Array(buffer);
  let binary = '';
  for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
  return btoa(binary);
}

Anthropic

typescript
body: JSON.stringify({
  model: 'claude-sonnet-4-20250514',
  max_tokens: 1024,
  messages: [{
    role: 'user',
    content: [
      {
        type: 'image',
        source: { type: 'base64', media_type: file.type, data: base64 },
      },
      { type: 'text', text: 'What is in this image?' },
    ],
  }],
});

OpenAI

typescript
body: JSON.stringify({
  model: 'gpt-4o',
  messages: [{
    role: 'user',
    content: [
      {
        type: 'image_url',
        image_url: { url: `data:${file.type};base64,${base64}` },
      },
      { type: 'text', text: 'What is in this image?' },
    ],
  }],
});

Gemini

typescript
body: JSON.stringify({
  contents: [{
    role: 'user',
    parts: [
      { inline_data: { mime_type: file.type, data: base64 } },
      { text: 'What is in this image?' },
    ],
  }],
});

Model Discovery

Build a model picker that reflects what the user actually has access to. Calling session.listModels(providerId) hits each provider's discovery endpoint through the proxy and returns a normalized list. For local providers (Ollama, LM Studio) this is the only way to know what the user has installed.

typescript
const models = await session.listModels('anthropic');
// → [
//     { id: 'claude-sonnet-4-6', displayName: 'Claude Sonnet 4.6',
//       contextWindow: 1_000_000, capabilities: { vision: true, reasoning: true }, raw: {...} },
//     { id: 'claude-haiku-4-5', ... },
//   ]

// Build a <select> from it
const options = models.map(m => ({
  value: m.id,
  label: m.displayName ?? m.id,
}));

Returned shape

idstring
Exact model ID to pass in chat requests.
providerIdstring
Provider this model is hosted on.
displayNamestring?
Human-readable label, when the provider supplies one.
contextWindownumber?
Max input context in tokens, when known.
capabilitiesPartial<ModelCapabilities>?
Best-effort flags (vision, tools, reasoning, structuredOutput). Some providers omit these — undefined means unknown, not unsupported.
rawunknown
Full provider payload for advanced consumers.

Per-provider behaviour

ProviderEndpoint hitNotes
openai, groq, deepseek, mistral, fireworks, openrouter, lm_studio/v1/modelsOpenAI-compatible {data:[...]} shape
anthropic/v1/modelsIncludes capabilities (vision, thinking, structured_outputs)
gemini/v1beta/modelsFilters out embedding-only models; strips the models/ prefix from IDs
cohere/v1/modelsFilters to chat-capable models; reads features for capabilities
together/v1/modelsReturns a plain array (no data wrapper) — handled transparently
azure_openai/openai/models?api-version=...Returns deployments rather than upstream model IDs
ollama/api/tagsLists what the user has ollama pull'd locally
perplexityNo public endpoint; returns a hardcoded Sonar list
xaiEndpoint not documented; throws PROVIDER_UNAVAILABLE

Failure modes

listModels can throw a ByokyError with one of these codes:

  • PROVIDER_UNAVAILABLE — the provider has no models endpoint, or returned 404/405.
  • INVALID_KEY — credential rejected by the provider (401/403).
  • RATE_LIMITED — upstream returned 429.
  • PROXY_ERROR — anything else (transport failure, malformed response).

Defensive callers should fall back to a hardcoded list. The demo shows the pattern.

Error Handling

Errors from upstream providers surface with their original HTTP status and body — so response.status and the usual { error: { message } } body shape work the same as hitting the provider directly.

The proxy layer adds its own error codes on top, signalled with an HTTP status and an error.code field in the JSON body. The table below covers every value of the ByokyErrorCode enum and what your app should do when it fires.

CodeMeaningWhat to do
WALLET_NOT_INSTALLEDNo extension or mobile wallet was detected when connect() ran.Show an install prompt — getStoreUrl() returns the right link per platform.
USER_REJECTEDUser dismissed the connect modal or denied the permission prompt.Do not auto-retry. Wait for an explicit user action before calling connect() again.
PROVIDER_UNAVAILABLEThe wallet has no credential (and no routing group) that can serve this provider.Tell the user which provider you need; deep-link them into the wallet to add a credential.
SESSION_EXPIREDSession was revoked from the wallet or aged out.Call byoky.connect() again. tryReconnect() handles silent restore.
RATE_LIMITEDUpstream provider returned 429 (their rate limit, not byoky's).Back off and retry. Honour the upstream Retry-After header.
QUOTA_EXCEEDEDA token-gift budget or wallet-imposed limit ran out (HTTP 429).Do not retry. Surface a "budget exhausted" UI — the gift/pool needs topping up.
INVALID_KEYStored credential was rejected by the provider (401).Prompt the user to update the credential in their wallet.
TOKEN_EXPIREDOAuth access token expired and the refresh attempt failed.Call connect() again — the wallet will re-run OAuth.
PROXY_ERRORGeneric proxy-layer failure (extension crashed, native message dropped, etc.).Safe to retry once. Surface a generic error if it repeats.
RELAY_CONNECTION_FAILEDBackend WebSocket relay could not reach the browser.Check the relay URL and that the user's tab is still open; retry with backoff.
RELAY_DISCONNECTEDRelay peer disconnected mid-request (user closed tab, network drop).Reopen the relay via session.createRelay(url).
UNKNOWNAnything that doesn't map to a specific code (last-resort fallback).Log the original message and surface a generic error. Treat as transient.

Handling quota errors

When a user redeems a Token Gift with a limited budget, or the wallet enforces per-session limits, requests fail with HTTP 429 and code: 'QUOTA_EXCEEDED'. Surface this to the user rather than retrying:

typescript
const fetch = session.createFetch('anthropic');
const res = await fetch(url, { method: 'POST', headers, body });

if (!res.ok) {
  const body = await res.json().catch(() => null);
  const code = body?.error?.code;

  if (res.status === 429 && code === 'QUOTA_EXCEEDED') {
    showQuotaExhaustedUI();
    return;
  }
  if (code === 'SESSION_EXPIRED') {
    await byoky.connect({ providers: [...], modal: true });
    return;
  }
  throw new Error(body?.error?.message ?? `HTTP ${res.status}`);
}

Listening for session lifecycle

typescript
session.onDisconnect(() => {
  // The user revoked access from the wallet, or the session expired.
  // Prompt them to reconnect before the next request.
  showReconnectBanner();
});

session.onProvidersUpdated((providers) => {
  // A credential was added/removed, or the user changed routing.
  // Refresh your UI's model picker.
  setAvailable(Object.entries(providers)
    .filter(([, v]) => v.available)
    .map(([id]) => id));
});

Limits & Quotas

Byoky enforces a small set of guardrails at the wallet, bridge, and submission layers. Most apps will never hit them — but knowing the numbers up front saves a debugging round-trip.

Connection rate limits

The wallet rate-limits how often a single origin can ask for a session, per the table below. Hitting either limit returns a RATE_LIMITED proxy error during connect(). These caps reset on a sliding 60-second window.

OperationLimitWindow
Connect requests per origin1060 seconds
OAuth refresh attempts per origin360 seconds

Provider-side rate limits (the HTTP 429 from Anthropic, OpenAI, etc.) are unaffected by these caps and surface as a RATE_LIMITED error with the upstream Retry-After header preserved.

Session lifetime

  • Session expiry is set by the wallet on approval. Treat it as opaque — listen for session.onDisconnect() rather than tracking time yourself.
  • byoky.tryReconnect() silently restores a previous session within the same tab. Useful as the first call on every page load.
  • Relay sessions (created by session.createRelay()) refresh internally every 10 minutes to bound the replay window for the auth token. No action required from your code.

Bridge body size

When apps route through @byoky/bridge (CLI / desktop), the bridge caps a single request body at 10 MB. Larger bodies fail with a 413 Payload Too Large at the proxy. The browser proxy path has no equivalent cap, but very large bodies still incur extra latency from chunking.

Marketplace submission caps

Field limits enforced by POST /v1/apps/submit. Submissions outside these bounds return HTTP 400 with the offending field named.

FieldConstraint
name≤ 100 characters
slug^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$ — lowercase alphanumeric and hyphens, 2–63 chars
url≤ 2048 characters, must be HTTPS, must be publicly resolvable, must allow iframe embedding
iconHTTPS URL (optional)
description≤ 1000 characters
categorychat, coding, trading, productivity, research, creative, or other
providersat least one valid provider ID
author.name≤ 100 characters
author.email≤ 320 characters
author.websiteHTTPS URL (optional)

Run npx create-byoky-app preflight to validate every field locally before submitting — it catches everything the server would reject, including iframe header issues.

What is not capped

  • Token usage — billed and metered by the upstream provider, not byoky.
  • Concurrent requests per session — bounded only by the user's connection.
  • Number of providers per session — request as many as the app actually needs.
  • Number of installed apps per wallet.

Backend Relay

Need LLM calls from your server? The user's browser relays requests through the extension — your backend never sees the API key.

Backend ←WebSocket→ User's Frontend ←Extension→ LLM API

Frontend

import { Byoky } from '@byoky/sdk';

const session = await new Byoky().connect({
  providers: [{ id: 'anthropic' }],
  modal: true,
});

// Open relay so your backend can make calls through this session
const relay = session.createRelay('wss://your-app.com/ws/relay');

Backend (Node.js)

typescript
import { ByokyServer } from '@byoky/sdk/server';

const byoky = new ByokyServer();

wss.on('connection', async (ws) => {
  const client = await byoky.handleConnection(ws);
  const fetch = client.createFetch('anthropic');

  const res = await fetch('https://api.anthropic.com/v1/messages', {
    method: 'POST',
    headers: {
      'content-type': 'application/json',
      'anthropic-version': '2023-06-01',
    },
    body: JSON.stringify({
      model: 'claude-sonnet-4-20250514',
      max_tokens: 1024,
      messages: [{ role: 'user', content: 'Hello!' }],
    }),
  });
});

Bridge (CLI / Desktop)

CLI tools and desktop apps route API calls through the bridge — a local HTTP proxy that relays requests to the extension via native messaging.

CLI App → HTTP → Bridge (localhost:19280) → Native Messaging → Extension → LLM API

Setup

bash
npm install -g @byoky/bridge
byoky-bridge install   # register native messaging host

Usage

Once installed, the bridge starts automatically when the extension needs it. CLI tools (like OpenClaw) make HTTP requests to http://127.0.0.1:19280/{provider}/, which the bridge forwards to the extension.

Token Gifts

Share token access without sharing your API key. The sender's wallet proxies all requests — the key never leaves the extension.

Sender's Extension ←WebSocket→ Relay Server ←WebSocket→ Recipient's Extension

Create a gift

  1. Open the wallet → select a credential → click "Gift"
  2. Set a token budget and expiry
  3. Share the generated gift link

Redeem a gift

  1. Open the wallet → click "Redeem Gift"
  2. Paste the gift link → accept

The recipient never receives your API key. Every request is relayed through the sender's running extension, which enforces the token budget and can revoke access at any time.

Token Pool

The Token Pool is a public board where users share free token gifts with the community.

How it works

  1. Create a gift in your wallet (extension or mobile)
  2. Check "List on Token Pool"
  3. Add a display name (or stay anonymous)
  4. Your gift appears on the token pool for anyone to redeem

What users see

  • Online/offline status — green dot if the gifter's wallet is online (gift is usable), red if offline
  • Tokens remaining — progress bar showing how much budget is left
  • Expiry countdown — time until the gift expires
  • Provider — which LLM provider the tokens are for

API endpoints

Pool listings live on the vault at vault.byoky.com. Online status is tracked live via the relay's WebSocket, and token usage updates flow through the proxy — neither needs a separate REST endpoint.

GET    /pool          — list currently-listed gifts (public)
POST   /pool/list     — list a gift publicly (bearer: gift authToken)
POST   /pool/unlist   — remove a listing (bearer: gift authToken)

Redemption goes through the short-link flow at byoky.com/g/:shortId, which resolves to the full gift link and opens the wallet's redeem view.

Cross-Provider Routing

Users can route your app's requests through a different provider than what your code targets. For example, your app calls anthropic but the user routes it through openai — the wallet transparently translates request/response bodies and SSE streams.

Your App (Anthropic SDK) → Wallet (translates) → OpenAI API
                                  ↕
              Anthropic ↔ OpenAI ↔ Gemini ↔ Cohere

How it works

  1. User creates groups in their wallet (e.g. "Claude", "GPT")
  2. Each group is pinned to a specific credential and provider
  3. Dragging an app between groups reroutes its traffic
  4. Request bodies, response bodies, and SSE streams are translated on the fly

No code changes required. Your app keeps calling its preferred SDK; the wallet handles the translation. Live sessions reroute automatically.

App Ecosystem

Build apps that users install directly into their Byoky wallet. Your app runs inside a sandboxed iframe (extension) or WebView (mobile) — full isolation from the wallet's keys and storage.

How marketplace apps work

  1. You build a web app that uses @byoky/sdk
  2. You host it on your own infrastructure (HTTPS required)
  3. You submit it to the marketplace for review
  4. Once approved, users can install it from the App Store inside their wallet
  5. Your app runs in a sandboxed environment — keys never touch your code

Security model

  • Apps run in sandboxed iframes (allow-scripts allow-forms) or native WebViews
  • Cross-origin isolation prevents access to wallet storage, DOM, or keys
  • All communication happens via the SDK's postMessage bridge
  • Installing an app auto-trusts its origin for the declared providers
  • Users can disable or uninstall apps at any time

Hosting requirements

Because your app loads inside an iframe in the Byoky extension, your server must allow iframe embedding. Do not set X-Frame-Options: DENY or SAMEORIGIN, and either omit Content-Security-Policy frame-ancestors or set it to something permissive:

http
Content-Security-Policy: frame-ancestors *

We verify this automatically at submission time and reject apps that would fail to load.

App Manifest

Every marketplace app needs a byoky.app.json manifest in the project root. Run npx create-byoky-app init to generate one interactively.

json
{
  "name": "TradeBot Pro",
  "slug": "tradebot-pro",
  "url": "https://tradebot.acme-ai.com",
  "icon": "/icon.png",
  "description": "AI-powered trading signals using your own API keys",
  "category": "trading",
  "providers": ["anthropic", "openai"],
  "author": {
    "name": "Acme AI",
    "email": "dev@acme-ai.com",
    "website": "https://acme-ai.com"
  }
}

Fields

namestring
Display name shown in the App Store and icon grid.
slugstring
URL-safe identifier. Must be unique across the marketplace.
urlstring
HTTPS URL where your app is hosted. This is what loads in the sandboxed iframe.
iconstring
URL to your app icon. Displayed as a rounded square in the app grid.
descriptionstring
Short description shown in the store listing.
categorystring
One of: chat, coding, trading, productivity, research, creative, other.
providersstring[]
Provider IDs your app needs (e.g. ["anthropic", "openai"]). Users approve which providers to grant on install.
authorobject
Author info: name (required), email (required), website (optional).

Review criteria

Submission (api.byoky.com/v1/apps/submit) enforces the automated checks; the rest are human-review criteria applied before the listing goes public.

  • App loads over HTTPS (automated)
  • App URL allows iframe embedding — no X-Frame-Options: DENY / SAMEORIGIN, no restrictive frame-ancestors (automated)
  • Slug, category, and provider IDs are valid (automated)
  • Uses @byoky/sdk for all LLM access (human review)
  • Only requests providers it actually uses (human review)
  • No obfuscated JavaScript (human review)
  • Privacy policy exists (human review)

Submitting Your App

Two paths get your app into the marketplace: the CLI (for developers already working in a byoky project) and the web form (for everyone else). Both hit the same review queue.

Create & ship an app with AI
Copy the full prompt — it covers scaffold, SDK integration, hosting requirements, and the exact submit command. Paste into Claude, ChatGPT, or Cursor to go from idea to submitted manifest in one session.

1. Scaffold or wire up an existing project

If you're starting fresh, generate a project with a working @byoky/sdk integration:

bash
npx create-byoky-app my-app
cd my-app
npm install

Pick a template (AI Chat / Multi-Provider / Backend Relay). The generator also drops a starter byoky.app.json in the project root.

If you already have an app, skip the scaffold and jump to the manifest step — any web app that uses @byoky/sdk and allows iframe embedding is eligible.

2. Create the manifest

Run the interactive generator from inside your project directory:

bash
npx create-byoky-app init

This writes byoky.app.json with your app name, slug, URL, description, category, providers, and author info. See the App Manifest section for the full field reference.

3. Host it with iframe embedding allowed

Your app URL must be HTTPS and must not block iframe embedding. The submission endpoint fetches your URL and rejects it if the response headers would prevent it from loading inside the wallet.

http
# Either omit X-Frame-Options entirely, or allow embedding via CSP:
Content-Security-Policy: frame-ancestors *

4. Submit

From inside your project (where byoky.app.json lives):

bash
npx create-byoky-app submit

This POSTs your manifest to https://api.byoky.com/v1/apps/submit. You'll see a confirmation line once the manifest is queued.

Prefer a form? Fill out the same fields at byoky.com/apps/submit.

5. Review & approval

  1. The endpoint validates field formats, checks that your URL is reachable over HTTPS, and verifies iframe embedding is allowed. Failures return a 400 with an explanation — fix and resubmit.
  2. Approved submissions appear at byoky.com/apps and in the App Store tab inside the extension, iOS, and Android wallets.
  3. We notify you at the author email you provided in the manifest.

Updating an approved app

Resubmit with the same slug. The existing listing's metadata updates on approval; the app URL itself can ship new versions anytime — users always load your current https:// URL, so deploying a new build is enough.