twinly. / Docs

Docs · 11

Developer API

One key and one base URL. Cloud endpoints (chat, computer-use actions, images) answer from Twinly's servers. Mac endpoints (send and read iMessages, place a call in your cloned voice, speak a line in your voice, run a full task) run on the key owner's Mac, in their running Twinly app, under their own approval settings. Every call is charged to the account that owns the key, on its existing plan.

01The basics

WhatValue
Base URLhttps://advktwnedlgnhswpxokg.supabase.co/functions/v1/twinly-api/v1
AuthAuthorization: Bearer twk_live_...
Modeltwinly-core
OpenAPI 3.1/openapi.yaml, for Postman, Insomnia, or any client generator
Mac endpoints needTwinly 4.1.2 or later, open on the key owner's Mac
Starter projectsgithub.com/OjasMishra32/twinly-api-examples: six projects with tests and a mock API, no installs
  • A key belongs to one Twinly account and acts as that person: texts come from their number, calls speak in their voice, tasks run on their Mac. Build with your own key on your own Mac.
  • Cloud endpoints answer in the request. Mac endpoints create a job the Mac picks up within seconds; you wait on it with ?wait= or get a webhook when it is done.
  • One usage pool. API calls spend the same plan and extra usage as the app. GET /v1/usage shows where you are.
  • Scopes limit each key to what a project needs.

02Which endpoint do I want?

I want toUseAnswers
Ask a model a question, with or without imagesPOST /v1/chat/completionsnow
Make a picturePOST /v1/imagesnow
Drive a screen my code controls, one step at a timePOST /v1/computer/next-actionnow
Text someone (with pictures, audio or files) from my numberPOST /v1/messagesjob, about 2 s
Read the latest messages in a chatGET /v1/messagesjob, about 1 s
Get my cloned voice saying something, as audioPOST /v1/voice/speakjob, about 3 s
Phone a business and get the transcriptPOST /v1/callsjob, minutes
Have my Twinly do a whole thing (research, browser, calendar, files, the screen)POST /v1/tasksjob, seconds to minutes
Get the answer back as JSON fieldsPOST /v1/tasks with output_schemajob
React when someone texts me, or build an @twinly botwebhookspush
Check my key and Mac before a demoGET /v1/menow, free

03Get a key

In the Twinly Mac app: Settings ▸ Developer API ▸ Create a key. Name it after your project and, if you like, set a monthly spending cap. The key is shown once: copy it into your server's environment or a password manager. Twinly keeps only a fingerprint and can never show it again; if you lose it, revoke it and make a new one.

export TWINLY_API_KEY=twk_live_...   # never commit this
export API=https://advktwnedlgnhswpxokg.supabase.co/functions/v1/twinly-api/v1

Scopes

A new key can do everything; untick what a project does not need in Settings ▸ Developer API. A request outside a key's scopes gets 403 scope_missing, and the message names the scope.

ScopeAllows
chatPOST /v1/chat/completions
computerPOST /v1/computer/next-action
imagesPOST /v1/images
messages.sendPOST /v1/messages
messages.readGET /v1/messages
callsPOST /v1/calls
voicePOST /v1/voice/speak
tasksPOST /v1/tasks
bots@twinly command webhooks (set up in the app)

GET /v1/me, GET /v1/usage, GET /v1/models and a key's own jobs need no scope.

04Quickstart

The chat endpoint speaks OpenAI's format, so the official openai packages work by changing the base URL.

curl https://advktwnedlgnhswpxokg.supabase.co/functions/v1/twinly-api/v1/chat/completions \
  -H "Authorization: Bearer $TWINLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model": "twinly-core", "messages": [{"role": "user", "content": "Write a haiku about Gainesville."}]}'
import os
from openai import OpenAI

client = OpenAI(
    base_url="https://advktwnedlgnhswpxokg.supabase.co/functions/v1/twinly-api/v1",
    api_key=os.environ["TWINLY_API_KEY"],
)
reply = client.chat.completions.create(
    model="twinly-core",
    messages=[{"role": "user", "content": "Write a haiku about Gainesville."}],
)
print(reply.choices[0].message.content)
import OpenAI from "openai";

const client = new OpenAI({
  baseURL: "https://advktwnedlgnhswpxokg.supabase.co/functions/v1/twinly-api/v1",
  apiKey: process.env.TWINLY_API_KEY,
});
const reply = await client.chat.completions.create({
  model: "twinly-core",
  messages: [{ role: "user", content: "Write a haiku about Gainesville." }],
});
console.log(reply.choices[0].message.content);

05Chat: POST /v1/chat/completions

FieldNotes
modeltwinly-core (or omit it)
messages1 to 256 messages; roles system, developer, user, assistant, tool
content partstext, and image_url with a base64 data URL (png, jpeg, webp, gif). Remote image URLs are not fetched. At most 8 images.
tools, tool_choicefunction calling, up to 128 functions
response_formattext, json_object, json_schema
temperature, top_p, presence_penalty, frequency_penalty, seed, stopthe usual ranges
max_tokens / max_completion_tokenscapped at 8192
nat most 4
reasoning_effortnone, minimal, low turn the model's thinking off (faster); medium, high turn it on. Default: on for text, off for image turns.
streamnot supported yet: leave it unset or false

Other fields are ignored. The response is a standard chat.completion object with choices and usage. Give your HTTP client a timeout of at least 120 seconds when thinking is on.

06Computer use: POST /v1/computer/next-action

Send a goal and a screenshot; get back one action. Your code performs it, takes a new screenshot, and calls again, until the action is done or fail. Twinly does not touch your computer; your code does. Coordinates are on a 0 to 1000 grid over the screenshot (x=0 left, 1000 right; y=0 top, 1000 bottom), so it works at any resolution.

Request field
goalrequired. What should be true when the task is finished. Up to 2000 characters.
screenshotrequired. Base64 PNG, JPEG or WebP (a data URL is fine), up to 4 MB. A JPEG about 1280 pixels wide is plenty and much faster.
previous_screenshotoptional. The screen before your last action, so the model can see whether it worked.
stepsoptional. The actions so far, oldest first, as returned (add result if you know what happened). Up to 50; the last 20 are used.
elementsoptional. On-screen things your code can see, with centers on the same grid: {"id": 3, "role": "button", "label": "Search", "x": 812, "y": 88}. Up to 300.
{
  "object": "computer.action",
  "model": "twinly-core",
  "coordinates": "0-1000",
  "action": {"type": "click", "x": 512, "y": 304, "label": "Search button", "reason": "Open the search box", "commits": false},
  "usage": {"prompt_tokens": 1890, "completion_tokens": 41, "total_tokens": 1931}
}
Action typeFields
click, double_clickx, y
typetext (goes where keyboard focus is)
keykeys, like return, tab, escape, cmd+l
scrollx, y, direction (up, down, left, right), amount (1 to 20)
waitseconds (0.2 to 10)
donetext holds the answer if the goal asked a question
failreason says what blocks it and what a person needs to do

07Images: POST /v1/images

Describe a picture; get it back as base64. prompt is required (up to 2000 characters); shape is square (1024x1024, the default), wide (1344x768) or tall (768x1344). The response is {"data": [{"b64_json": "...", "mime": "image/jpeg"}]}. A description the image service declines is 422 content_filtered and is not charged.

curl -s "$API/images" \
  -H "Authorization: Bearer $TWINLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"prompt": "a gator in sunglasses on a skateboard, cartoon", "shape": "wide"}'

The image service will not draw a real person from their name. Describe their signature look instead, add “no text or lettering”, and put the names on the picture yourself.

08Mac endpoints: how jobs work

Messages, calls, voice and tasks do not answer from Twinly's servers. Each request becomes a job: the key owner's running Twinly picks it up within seconds, does it the way it does a request typed into the app (their approval settings, Stop and Undo all apply), and reports back. It shows in the owner's task list as “API · <key name> · <kind>”. Because nobody is watching the Mac, three things differ:

  • No send preview. A message your job sends goes out when the job runs. A step the owner's approval settings hold still waits for them, and the job shows needs_approval.
  • Nothing in the notch. A job's steps and results stay out of the island; the owner sees them in the task list.
  • A task never stops to ask a question. It makes the sensible choice, or ends done with its question in answer. Put everything it needs in the prompt.

Set up the Mac first. Texts need Messages signed in plus Full Disk Access and Messages automation; voice needs a voice clone (Twinly ▸ Voice, else voice_not_set_up); calls need Google Voice set up; tasks that use the screen need Screen Recording and Accessibility. Running Set everything up on Twinly's Access screen once avoids a job waiting on a permission card.

Twinly must be open on the owner's Mac. If it has not checked in for 90 seconds, the request fails at once with 409 twinly_offline and nothing is queued. A queued job whose Mac goes away expires 2 minutes later (twinly_offline); a job behind others waits up to 30 minutes, then expires (queue_timeout). Only a job that ends done is charged.

Waiting for the answer

Every job endpoint takes ?wait=<seconds> (0 to 25). If the job finishes in time you get 200 with it; if not, 202 with its current status, and you call GET /v1/jobs/{id}?wait=25, which answers as soon as anything changes. Messages and voice wait 25 seconds by default, so most answer in one call; calls and tasks do not wait by default.

The job object

{
  "id": "6f1d2c3b-...",
  "object": "job",
  "kind": "messages.send",
  "status": "done",
  "created_at": "2026-10-21T20:00:00.000Z",
  "updated_at": "2026-10-21T20:00:02.000Z",
  "result": {"to": "+13525550100", "delivered": true, "sent_at": "2026-10-21T20:00:02Z"},
  "error": null
}
StatusMeans
queuedwaiting for the owner's Mac to pick it up
runningthe Mac is doing it
needs_approvalthe owner's approval settings held a step; it continues when they decide in the app
donefinished; result holds the answer
failederror.code says why: timeout, voice_not_set_up, voice_starting (retry in a minute), and others
canceledcanceled through the API before it started, or stopped by the owner
expiredit never started: twinly_offline or queue_timeout

A job runs for at most 30 minutes (calls), 15 minutes (tasks) or 2 minutes (everything else), then fails with timeout. Jobs are kept for 7 days, and a key sees only its own.

09Send and read messages

POST /v1/messages

curl -s "$API/messages" \
  -H "Authorization: Bearer $TWINLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"to": "+13525550100", "text": "Running 10 minutes late!"}'
Field
to or chatexactly one: to is a phone number, email or contact name; chat is a group chat's id or name
textup to 4000 characters (may be empty when you send an attachment)
attachmentsup to 4: an https URL on a public host (the Mac downloads it, up to 25 MB), or a file inline as {"name": "meme.jpg", "mime": "image/jpeg", "b64": "..."} (up to 25 MB)

Result: {"to": "resolved handle or chat id", "delivered": true, "sent_at": "..."} (delivered is null when Messages cannot tell yet). A message request can be at most 34 MB, enough for one 25 MB file inline; send larger sets as URLs.

GET /v1/messages

Query chat (a chat id or name) or with (a phone number, email or contact), and limit (1 to 50, default 20).

{"messages": [{"from": "me", "name": null, "text": "On my way", "at": "2026-10-21T19:58:00Z"},
              {"from": "+13525550100", "name": "Mom", "text": "Drive safe", "at": "2026-10-21T19:59:00Z"}]}

10Place a call: POST /v1/calls

Twinly calls the number in the owner's cloned voice, works toward the goal, and hangs up. Fields: to (required, a number or contact name), goal (required, up to 2000 characters), say (optional first line, up to 1000). The answer is 202 with a queued job; wait on it.

curl -s "$API/calls" \
  -H "Authorization: Bearer $TWINLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"to": "+13525550100", "goal": "Book a table for 2 at 7pm Friday under Sam", "say": "Hi! I would like to book a table."}'

Result: {"minutes": 3, "transcript": [{"who": "them", "text": "..."}, {"who": "twin", "text": "..."}], "outcome": "Booked for 7pm Friday."}

11Speak in the owner's voice: POST /v1/voice/speak

text is up to 600 characters. Result: {"audio_b64": "...", "mime": "audio/mp4", "seconds": 2.9}. Send it as a voice note by passing it to POST /v1/messages as an inline attachment with empty text.

curl -s "$API/voice/speak" \
  -H "Authorization: Bearer $TWINLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"text": "Happy birthday! Sorry I could not be there."}'

12Run a task: POST /v1/tasks

Anything you could ask Twinly in the app: research, the browser, the owner's calendar, files, and the Mac's screen when a job needs it. prompt is up to 4000 characters; a path the task should open is best written in full. Result: {"answer": "...", "files": [{"name": "x.jpg", "mime": "image/jpeg", "b64": "..."}]} (at most 4 files, each up to 8 MB).

import os, requests
API = "https://advktwnedlgnhswpxokg.supabase.co/functions/v1/twinly-api/v1"
H = {"Authorization": f"Bearer {os.environ['TWINLY_API_KEY']}"}

job = requests.post(f"{API}/tasks", headers=H, json={"prompt": "Find three flights to Austin under $300 next weekend"}).json()
while job["status"] not in ("done", "failed", "canceled", "expired"):
    job = requests.get(f"{API}/jobs/{job['id']}", headers=H, params={"wait": 25}, timeout=40).json()
print(job["result"]["answer"] if job["status"] == "done" else job["error"])

Get data back, not a paragraph

Pass output_schema, a JSON Schema (up to 8 KB), and the result also carries data, parsed and ready to use. The task fills the shape with what it actually found and uses null for anything it could not. If its answer has no JSON that parses, data is null and data_error says so; the full text is still in answer.

schema = {"type": "array", "items": {"type": "object", "properties": {
    "airline": {"type": "string"}, "price": {"type": "number"}, "departs": {"type": "string"}}}}
job = requests.post(f"{API}/tasks", headers=H, json={
    "prompt": "Three cheapest flights GNV to AUS next Friday", "output_schema": schema}).json()

13Check or cancel a job

GET /v1/jobs/{id}?wait=25 returns the job: 200 when it is finished, 202 while it is not. The wait holds the request open until the job finishes or its status changes, so looping on it gives one answer per change with no tight polling. Another key's job id is 404 job_not_found.

POST /v1/jobs/{id}/cancel cancels a job that is still queued (canceling twice is fine). Once the Mac has it, a text may already be sending or a phone ringing, so the API cannot take it back: 409 job_running, and the owner can stop it in Twinly. A finished job is 409 job_finished.

14Webhooks and bots

The owner's Mac can push events to a URL you own, so you do not have to poll, and it can hand @twinly <command> messages to your code and text back what you answer. Set it up in Settings ▸ Developer API ▸ Webhooks:

  1. Webhook URL: any https:// address, or plain http://127.0.0.1:<port>/... for a server on the same Mac (no tunnel needed, the Mac sends the webhook itself).
  2. Show secret, and keep it in your server's environment. Send test event posts a webhook.test.
  3. Optional: Bot commands (words like weather), and the chats whose new messages you want as message.received.

Every event is {"id": "evt_...", "type": "...", "created_at": "...", "data": {...}}.

TypeSent whendata
job.finishedany job endsthe job object (result_omitted: true if over 6 MB; fetch it with GET /v1/jobs/{id})
call.endeda call your key placed endsjob_id, to, minutes, transcript, outcome
message.receivedsomeone texts a chat you tickedchat, from, name, text, at
bot.commandanyone texts @twinly <your command> ...command, args, chat, from, from_me, text
webhook.testyou press Send test eventmessage

Undelivered events (no answer, or not 2xx) are retried after 1, 4 and 15 seconds; bot.command is not retried, so a reply is never posted twice. Use id to drop a duplicate.

Check the signature

Every POST carries X-Twinly-Signature: t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 with your secret over <t>.<raw body>. Check it on the raw bytes, before parsing, and refuse a t more than 5 minutes old.

import crypto from "node:crypto";
export function verifySignature(header, rawBody, secret, now = Date.now() / 1000) {
  const parts = Object.fromEntries((header || "").split(",").map((p) => p.split("=", 2)));
  const t = Number(parts.t);
  if (!t || !parts.v1 || Math.abs(now - t) > 300) return false;
  const want = crypto.createHmac("sha256", secret).update(t + ".").update(rawBody).digest("hex");
  return want.length === parts.v1.length && crypto.timingSafeEqual(Buffer.from(want), Buffer.from(parts.v1));
}
import hmac, hashlib, time
def verify_signature(header, raw_body, secret, now=None):
    parts = dict(p.split("=", 1) for p in (header or "").split(",") if "=" in p)
    t, v1 = int(parts.get("t", 0)), parts.get("v1", "")
    if not t or abs((now or time.time()) - t) > 300:
        return False
    want = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(want, v1)

Bots: answer @twinly commands

When a message starts with @twinly and one of your bot commands, Twinly sends bot.command and waits 10 seconds for your answer. Reply 2xx with JSON and Twinly texts it into that chat from the owner's number:

{"reply": "Gainesville: 74°F and sunny", "attachments": ["https://example.com/radar.png"]}
  • attachments are optional https links; Twinly downloads and sends them.
  • Anything other than a 2xx in time is no reply; the owner gets a note on the Mac.
  • Your command takes priority over Twinly's own answer for that message.
  • Anyone in the chat can use your command; people other than the owner get 6 a minute each. from_me is true when the owner sent it.
  • Bot commands need a key with the bots scope on the account.

15Readiness and usage

GET /v1/me needs no scope and costs nothing. Call it first, and again before a demo. mac.online is true while the owner's Twinly checked in within 90 seconds (the same test behind 409 twinly_offline), hint says what to do when it is false, and can lists what this key may call right now.

{
  "object": "me",
  "key": {"id": "...", "name": "hackathon", "prefix": "twk_live_abc123", "scopes": ["chat", "images", "tasks"]},
  "mac": {"online": true, "last_seen": "2026-10-21T22:00:00.000Z", "app_version": "4.1.2", "hint": null},
  "can": {"cloud": ["POST /v1/chat/completions", "POST /v1/images"], "mac": ["POST /v1/tasks"]}
}

GET /v1/usage returns this key's month (requests, usage in dollars, its cap) and the account's plan (percent left today and this week, reset times, extra usage). On the free plan it returns prompts_per_day and prompts_left_today. GET /v1/models lists twinly-core.

16Pricing and limits

WhatPrice
chat, computer next-actionwhat the model actually used
an image$0.05
sending a message$0.05
reading messages$0.02
speaking a line$0.10
a task$0.20, plus what the model uses while the task runs
a call$0.30 per started minute, at least one minute

You are charged only when a job succeeds: its price is set aside when it is created (a call sets aside 5 minutes), charged if it ends done (a call for the minutes it really took), and released otherwise. If the account cannot cover the price, the request is 402 insufficient_usage and nothing is queued.

LimitValue
Free planeach API request and each job uses one of the account's 5 free prompts a day, shared with the app; a request that fails before the model answers, or a job that does not succeed, gives its prompt back
Paid plansthe plan's daily and weekly limits, shared with the app; extra usage extends them
Requests per minute30 per key, plus the account's share of engine capacity
Requests per day2000 per account
Keys5 live keys per account, 20 new keys a day
Monthly capoptional per key, $5 to $10,000
Request size6 MB (34 MB for POST /v1/messages)
Output8192 tokens per choice, 4 choices

17Errors

Errors use OpenAI's shape, so the SDKs raise their usual exceptions: {"error": {"message": "...", "type": "...", "code": "invalid_api_key", "param": null}}.

StatusCodeMeaningRetry?
400invalid_json, invalid_messages, invalid_parameter, invalid_recipient, invalid_attachments, ...the request is wrong; param says whereno, fix it
401missing_api_key, invalid_api_keyno key, a bad key, or a revoked keyno
402free_prompts_used, plan_limit_reached, insufficient_usage, key_cap_reached, ...out of usage for now, or the key hit its capafter the reset, or add extra usage
403origin_not_allowedcalled from a web pagecall from a server
403scope_missingthe key lacks that scopeturn it on in the app
404not_found, job_not_foundunknown endpoint, or not this key's jobno
409twinly_offlinethe owner's Twinly is not runningyes, once it is open
409job_running, job_finishedthe job cannot be canceled nowno
413request_too_largeover 6 MB (34 MB for a message)no, shrink it
422content_filteredthe image service declined the descriptionno
429rate_limited, fair_share_exceededtoo fastyes, after Retry-After
502invalid_model_output, upstream_timeout, ...the model's answer was not usableyes
503engine_busy, engine_unavailable, ...Twinly's side, temporarilyyes, after Retry-After
503job_unverifiedTwinly could not confirm a job was createdcheck the job before retrying, so a text is not sent twice

18Security

  • Server side only. A key in a web page, a mobile app or a public repo is a key anyone can use, charged to you. Keep it in an environment variable or a secrets manager.
  • One key per project, with only the scopes it needs, and a monthly cap on anything you share with a team.
  • Mac endpoints act as the owner. A message comes from their number and a call speaks in their voice. Twinly applies the owner's approval settings to every job; to see each one before it goes out, set an asking mode in the app.
  • Rotate and revoke in Settings ▸ Developer API. Revocation takes effect on the key's next request.
  • What Twinly stores: only a SHA-256 fingerprint of each key and its first 15 characters. Keys always start with twk_live_ followed by 43 letters and digits, so secret scanners can catch a leaked one.

Codes from events. Got a code at a hackathon or from the Twinly team? Redeem it in Settings ▸ Developer API ▸ Redeem a code (also on Account). Its value lands on the account's extra usage, which every key draws on. Each account can redeem a code once.

19Project ideas

The first six have a ready-to-run starter in twinly-api-examples, with tests and a mock API so you can build before your Mac is set up.

IdeaUses
A group-chat bot: @twinly roll, @twinly trivia, @twinly weatherbots
A nightly digest of a busy group chat, texted to youmessages read, chat, messages
Voice postcards: type a line, a friend gets it in your voicevoice, messages
A meme machine that texts the picture to the chatimages, messages
A phone agent that calls a business and brings back the transcriptcalls
A command-line “do this for me” for anything Twinly can dotasks
A price or flight watcher that texts you when it dropstasks with output_schema, messages, on a schedule
A QA tester that clicks through your web app and reports what brokecomputer next-action
Reminders that call you with your daycalls on a schedule

20When something goes wrong

You seeIt meansDo this
409 twinly_offlineTwinly has not checked in for 90 secondsopen Twinly on the key owner's Mac, keep it awake, confirm with GET /v1/me
a job stays needs_approvalthe owner's approval setting holds that kind of stepapprove it in the app, or change the approval mode
a job sits running and nothing happensTwinly is waiting on a permission the first timeallow Twinly's card in the app, or run Set everything up once
voice_not_set_upno voice clone on that Macrecord one in Twinly ▸ Voice
402 insufficient_usagethe plan and extra usage cannot cover the jobadd extra usage in Account, or redeem a code
the bot never answersno key with the bots scope, a wrong webhook URL, or no reply within 10 spress Send test event in Settings and answer within 10 s
your server rejects every webhookthe signature check runs on parsed JSONverify on the raw body bytes, before parsing
a text shows Not Deliveredthe person is not on iMessage, or the number is wrongcheck the number; the job's result.delivered says so too