Voice Translator - API

Segment-by-segment speech translation from your own code.

Translate one segment of speech

The app cuts speech into short segments at each pause and translates each one with one call. You can do the same: send the segment (as recognised, unpunctuated is fine), the two languages and the last few translated segments as context. You get back one line of JSON with the translation, the language it was actually spoken in, an optional pronunciation guide and an optional back-translation. Listening and the spoken voice happen in the browser app; the API is the text step in the middle.

Basics

Base URL https://api.skillsafe.ai/v1/app-api. Every call except POST /guest sends Authorization: Bearer <token>; bodies are JSON. The token is bound to this app, so there is no app header: the slug appears only in the POST /guest body.

Responses use one envelope: {"ok":true,"data":{...}} on success and {"ok":false,"error":{"code","message","details"}} on failure. /estimate, /run and /run-stream take the input object itself as the body - never wrap it as {"input":{...}}.

Credits: 1 credit = $0.0001. /estimate is free and creates no job; a run holds up to hold_credits and charges charged_credits when it finishes. One segment is one run.

Statuserror.codeMeaning
400validation_errorA field of the wrong type, or an object where a scalar belongs; details names it.
401unauthorizedInvalid or expired token. Get a new one (step 1).
402payment_requiredThe balance is below min_credits. Top up at skillsafe.ai.
403forbiddenA guest token cannot run metered turns unless the app sponsors guests. Sign in for a personal token.
404not_foundUnknown app slug, or a job id that is not yours.
409conflictThe Idempotency-Key was used before with a different body.
429rate_limitedBack off and retry with the same Idempotency-Key.
5xx-Transient. Retry with backoff and the same key.

Browsers enforce CORS for this API: run the samples from a server, script or terminal.

The task field first

There is one task, translate. Always send "task": "translate".

taskWhat it doesReply
translateTranslate one segment of speech from source_lang to target_lang, using the earlier segments in context for names, pronouns and register. If the segment was actually spoken in the target language it is translated the other way.One line of JSON: translation, to, detected, romanization, back, note.

Input fields

Every field is a scalar (strings, and 0/1 numbers for the two switches). Send one segment per call, at most 600 characters; the app splits longer text at sentence ends and sends the pieces in order.

FieldTypeMeaning
taskstringtranslate.
textstringThe segment as heard or typed. Fillers, missing punctuation and recognition slips are expected.
source_lang / source_namestringThe speaker's language: a code (en, es, zh, zh-Hant, ja ...) and its English name.
target_lang / target_namestringThe listener's language, code and English name.
modestringoneway (one speaker) or chat (two people taking turns).
speakerstringA or B in chat mode, empty otherwise. In chat mode person B speaks target_lang, so swap source and target for B's turns.
contextstringUp to four earlier translated segments, oldest first, one per line: A (en): original => translation. Each side is clipped to 280 characters at a word boundary. Empty for the first segment.
glossarystringOptional lines source = target; a line with both sides equal is a name to keep. Up to 40 lines, 1,200 characters.
tonestringauto | formal | casual: how the listener is addressed (usted/tu, vous/tu, Sie/du, polite or plain Japanese).
want_romanizationnumber1 asks for a pronunciation guide in Latin letters when the translation is in a non-Latin script (pinyin, Hepburn, Revised Romanization ...).
want_backnumber1 asks for a literal back-translation into the speaker's language.
numbersstringThe numbers found in text, separated by | . The reply must keep each one in digits. The app finds them with a digit pattern; send an empty string if you do not.
retry_notestringOptional: a format correction after a reply that could not be read; send it with a new attempt number in the key.

Reply contract

One line of JSON, nothing else: {"translation":"...","to":"es","detected":"en","romanization":"","back":"","note":""}. Take the text from the first { to the last } and parse it. If that fails, the reply was cut short: read the translation string up to where it stops and mark it partial.

FieldMeaning
translationThe translation, in the script of to. Never empty.
toThe language translated into: target_lang, or source_lang when the segment was spoken in the target language.
detectedThe language the segment was actually spoken in.
romanizationThe pronunciation guide when asked for and the script is not Latin; otherwise empty.
backThe back-translation when asked for; otherwise empty.
noteUp to 25 words in English: a corrected mishearing, a real ambiguity, a formality choice. Usually empty.

Check the reply as the app does: every number from numbers appears in translation with the same digits, every glossary target appears, and detected matches the language you expected. The app shows a warning on the segment when one of these fails.

Step 1 - A tiny client, and a token

POST /guest

A short helper adds the auth header, sends JSON and unwraps the envelope; every later step reuses it. For the token, open the token page, sign in with SkillSafe and copy the token. A personal token bills runs to your account. A script with no browser can mint a guest token with POST /guest and the body {"slug":"voice-translator"}, sent with no auth header; guests can estimate but not run.

export API="https://api.skillsafe.ai/v1/app-api"
export SKILLSAFE_TOKEN="YOUR_TOKEN"   # "Copy shell export" on /tokens.html

# No browser? A guest token (this call has no Authorization header):
SKILLSAFE_TOKEN=$(curl -s -X POST "$API/guest" -H "Content-Type: application/json" \
  -d '{"slug":"voice-translator"}' | jq -r '.data.token')
import json, time, requests

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"  # from tokens.html

def api(method, path, body=None, headers=None):
    h = {"Content-Type": "application/json", "Authorization": f"Bearer {TOKEN}", **(headers or {})}
    res = requests.request(method, API + path, json=body, headers=h, timeout=180)
    payload = res.json()
    if not res.ok or payload.get("ok") is False:
        err = payload.get("error", {})
        raise RuntimeError(f"{res.status_code} {err.get('code')}: {err.get('message')}")
    return payload["data"]
// Node 18+ (global fetch)
const API = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // from tokens.html

async function api(method, path, body, headers = {}) {
  const res = await fetch(API + path, {
    method,
    headers: { "Content-Type": "application/json", Authorization: `Bearer ${TOKEN}`, ...headers },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const payload = await res.json();
  if (!res.ok || payload.ok === false) {
    throw new Error(`${res.status} ${payload.error?.code}: ${payload.error?.message}`);
  }
  return payload.data;
}
package main

import ("bytes"; "encoding/json"; "fmt"; "net/http"; "time")

const API = "https://api.skillsafe.ai/v1/app-api"
var token = "YOUR_TOKEN" // from tokens.html

func call(method, path string, body any, headers map[string]string, out any) error {
	var buf bytes.Buffer
	if body != nil { json.NewEncoder(&buf).Encode(body) }
	req, _ := http.NewRequest(method, API+path, &buf)
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+token)
	for k, v := range headers { req.Header.Set(k, v) }
	res, err := (&http.Client{Timeout: 180 * time.Second}).Do(req)
	if err != nil { return err }
	defer res.Body.Close()
	var env struct {
		Ok    bool            `json:"ok"`
		Data  json.RawMessage `json:"data"`
		Error struct{ Code, Message string } `json:"error"`
	}
	json.NewDecoder(res.Body).Decode(&env)
	if !env.Ok { return fmt.Errorf("%d %s: %s", res.StatusCode, env.Error.Code, env.Error.Message) }
	return json.Unmarshal(env.Data, out)
}
// Java 11+, Jackson for JSON
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.node.*;
import java.net.URI;
import java.net.http.*;
import java.util.Map;

static final String API = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = "YOUR_TOKEN"; // from tokens.html
static final ObjectMapper JSON = new ObjectMapper();
static final HttpClient HTTP = HttpClient.newHttpClient();

static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
    var b = HttpRequest.newBuilder(URI.create(API + path))
        .header("Content-Type", "application/json").header("Authorization", "Bearer " + TOKEN)
        .method(method, body == null ? HttpRequest.BodyPublishers.noBody()
                                     : HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(body)));
    headers.forEach(b::header);
    var res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
    JsonNode env = JSON.readTree(res.body());
    if (!env.path("ok").asBoolean()) throw new RuntimeException(res.statusCode() + " " + env.path("error"));
    return env.get("data");
}
require "net/http"
require "json"

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from tokens.html

def api(method, path, body = nil, headers = {})
  uri = URI(API + path)
  req = Net::HTTP.const_get(method.capitalize).new(uri)
  req["Content-Type"] = "application/json"
  req["Authorization"] = "Bearer #{TOKEN}"
  headers.each { |k, v| req[k] = v }
  req.body = body.to_json if body
  res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 180) { |h| h.request(req) }
  payload = JSON.parse(res.body)
  raise "#{res.code} #{payload.dig('error', 'code')}: #{payload.dig('error', 'message')}" unless payload["ok"]
  payload["data"]
end
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // from tokens.html

function api(string $method, string $path, ?array $body = null, array $headers = []): array {
    $h = ["Content-Type: application/json", "Authorization: Bearer " . TOKEN];
    foreach ($headers as $k => $v) $h[] = "$k: $v";
    $ch = curl_init(API . $path);
    curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h,
        CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 180]);
    if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
    $payload = json_decode(curl_exec($ch), true);
    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    if (empty($payload["ok"])) throw new RuntimeException("$code " . json_encode($payload["error"] ?? null));
    return $payload["data"];
}
using System.Net.Http.Json;
using System.Text.Json;

static class Translator
{
    const string Api = "https://api.skillsafe.ai/v1/app-api";
    public static string Token = "YOUR_TOKEN"; // from tokens.html
    static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(180) };

    public static async Task<JsonElement> ApiAsync(HttpMethod method, string path, object? body = null,
        Dictionary<string, string>? headers = null)
    {
        var req = new HttpRequestMessage(method, Api + path);
        req.Headers.Add("Authorization", "Bearer " + Token);
        foreach (var (k, v) in headers ?? new()) req.Headers.Add(k, v);
        if (body != null) req.Content = JsonContent.Create(body);
        var res = await Http.SendAsync(req);
        var env = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
        if (!env.GetProperty("ok").GetBoolean()) throw new Exception($"{(int)res.StatusCode} {env.GetProperty("error")}");
        return env.GetProperty("data").Clone();
    }
}

Step 2 - Who am I, and what is my balance?

GET /me

subject_type is user for a personal token and guest otherwise; credits is the balance.

curl -s "$API/me" -H "Authorization: Bearer $SKILLSAFE_TOKEN" | jq '.data | {subject_type, credits}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
var me struct {
	SubjectType string `json:"subject_type"`
	Credits     int    `json:"credits"`
}
if err := call("GET", "/me", nil, nil, &me); err != nil { panic(err) }
fmt.Println(me.SubjectType, me.Credits)
JsonNode me = api("GET", "/me", null, Map.of());
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts me.values_at("subject_type", "credits").inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? 0, PHP_EOL;
var me = await Translator.ApiAsync(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")} {me.GetProperty("credits")}");

Step 3 - Price a segment for free

POST /estimate

/estimate takes the same body as a run and creates no job. It returns hold_credits (reserved while the run is in flight) and min_credits. A segment is charged charged_credits, usually far less than the hold.

jq -n '{task: "translate", text: "um so the train leaves at 7:30 from platform 4 where do I buy a ticket",
  source_lang: "en", source_name: "English", target_lang: "es", target_name: "Spanish",
  mode: "oneway", speaker: "", context: "", glossary: "", tone: "auto",
  want_romanization: 0, want_back: 1, numbers: "7:30 | 4"}' > segment.json

curl -s -X POST "$API/estimate" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
  -d @segment.json | jq '.data | {hold_credits, min_credits, model_alias, warnings}'
segment = {"task": "translate", "text": "um so the train leaves at 7:30 from platform 4 where do I buy a ticket",
           "source_lang": "en", "source_name": "English", "target_lang": "es", "target_name": "Spanish",
           "mode": "oneway", "speaker": "", "context": "", "glossary": "", "tone": "auto",
           "want_romanization": 0, "want_back": 1, "numbers": "7:30 | 4"}
est = api("POST", "/estimate", segment)
print(est["hold_credits"], est["min_credits"])
const segment = { task: "translate", text: "um so the train leaves at 7:30 from platform 4 where do I buy a ticket",
  source_lang: "en", source_name: "English", target_lang: "es", target_name: "Spanish",
  mode: "oneway", speaker: "", context: "", glossary: "", tone: "auto",
  want_romanization: 0, want_back: 1, numbers: "7:30 | 4" };
const est = await api("POST", "/estimate", segment);
console.log(est.hold_credits, est.min_credits);
segment := map[string]any{"task": "translate", "text": "um so the train leaves at 7:30 from platform 4 where do I buy a ticket",
	"source_lang": "en", "source_name": "English", "target_lang": "es", "target_name": "Spanish",
	"mode": "oneway", "speaker": "", "context": "", "glossary": "", "tone": "auto",
	"want_romanization": 0, "want_back": 1, "numbers": "7:30 | 4"}
var est struct {
	Hold int `json:"hold_credits"`
	Min  int `json:"min_credits"`
}
if err := call("POST", "/estimate", segment, nil, &est); err != nil { panic(err) }
fmt.Println(est.Hold, est.Min)
ObjectNode segment = JSON.createObjectNode()
    .put("task", "translate").put("text", "um so the train leaves at 7:30 from platform 4 where do I buy a ticket")
    .put("source_lang", "en").put("source_name", "English").put("target_lang", "es").put("target_name", "Spanish")
    .put("mode", "oneway").put("speaker", "").put("context", "").put("glossary", "").put("tone", "auto")
    .put("want_romanization", 0).put("want_back", 1).put("numbers", "7:30 | 4");
JsonNode est = api("POST", "/estimate", segment, Map.of());
System.out.println(est.get("hold_credits") + " " + est.get("min_credits"));
segment = { task: "translate", text: "um so the train leaves at 7:30 from platform 4 where do I buy a ticket",
            source_lang: "en", source_name: "English", target_lang: "es", target_name: "Spanish",
            mode: "oneway", speaker: "", context: "", glossary: "", tone: "auto",
            want_romanization: 0, want_back: 1, numbers: "7:30 | 4" }
est = api("POST", "/estimate", segment)
puts est.values_at("hold_credits", "min_credits").inspect
$segment = ["task" => "translate", "text" => "um so the train leaves at 7:30 from platform 4 where do I buy a ticket",
    "source_lang" => "en", "source_name" => "English", "target_lang" => "es", "target_name" => "Spanish",
    "mode" => "oneway", "speaker" => "", "context" => "", "glossary" => "", "tone" => "auto",
    "want_romanization" => 0, "want_back" => 1, "numbers" => "7:30 | 4"];
$est = api("POST", "/estimate", $segment);
echo $est["hold_credits"], " ", $est["min_credits"], PHP_EOL;
var segment = new Dictionary<string, object> {
    ["task"] = "translate", ["text"] = "um so the train leaves at 7:30 from platform 4 where do I buy a ticket",
    ["source_lang"] = "en", ["source_name"] = "English", ["target_lang"] = "es", ["target_name"] = "Spanish",
    ["mode"] = "oneway", ["speaker"] = "", ["context"] = "", ["glossary"] = "", ["tone"] = "auto",
    ["want_romanization"] = 0, ["want_back"] = 1, ["numbers"] = "7:30 | 4" };
var est = await Translator.ApiAsync(HttpMethod.Post, "/estimate", segment);
Console.WriteLine($"{est.GetProperty("hold_credits")} {est.GetProperty("min_credits")}");

Step 4 - Translate the segment

POST /run   GET /jobs/{job_id}

Send the body with an Idempotency-Key: a retry with the same key and body never bills twice. Poll the job until it is succeeded or failed, then read the reply from output.output. If truncated is true the balance cut the reply short - keep what arrived and mark it partial.

BODY=$(jq -c . segment.json)
KEY="voice-translator:translate:$(printf %s "$BODY" | shasum | cut -c1-16):1"
JOB_ID=$(curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" -d "$BODY" | jq -r '.data.job_id')

while :; do
  JOB=$(curl -s "$API/jobs/$JOB_ID" -H "Authorization: Bearer $SKILLSAFE_TOKEN")
  STATUS=$(echo "$JOB" | jq -r '.data.status')
  [ "$STATUS" = succeeded ] || [ "$STATUS" = failed ] && break
  sleep 1
done
echo "$JOB" | jq '.data | {status, charged_credits, truncated}'
echo "$JOB" | jq -r '.data.output.output' | jq '{translation, to, detected, back, note}'
import hashlib

def reply(text):
    a, b = text.find("{"), text.rfind("}")
    return json.loads(text[a:b + 1])

body = json.dumps(segment)
key = "voice-translator:translate:" + hashlib.sha1(body.encode()).hexdigest()[:16] + ":1"
job = api("POST", "/run", segment, {"Idempotency-Key": key})
while job.get("status") not in ("succeeded", "failed"):
    time.sleep(1)
    job = api("GET", f"/jobs/{job['job_id']}")
out = reply((job.get("output") or {}).get("output", ""))
print(out["translation"], "|", out["back"], "|", job.get("charged_credits"), "credits")
# every number the speaker said must survive, in digits
for n in segment["numbers"].split(" | "):
    digits = "".join(c for c in n if c.isdigit())
    if digits and digits not in "".join(c for c in out["translation"] if c.isdigit()):
        print("check this number:", n)
import { createHash } from "node:crypto";

const reply = (t) => JSON.parse(t.slice(t.indexOf("{"), t.lastIndexOf("}") + 1));
const body = JSON.stringify(segment);
const key = `voice-translator:translate:${createHash("sha1").update(body).digest("hex").slice(0, 16)}:1`;
let job = await api("POST", "/run", segment, { "Idempotency-Key": key });
while (!["succeeded", "failed"].includes(job.status)) {
  await new Promise((r) => setTimeout(r, 1000));
  job = await api("GET", `/jobs/${job.job_id}`);
}
const out = reply(job.output?.output || "");
console.log(out.translation, "|", out.back, "|", job.charged_credits, "credits");
type Job struct {
	JobID   string `json:"job_id"`
	Status  string `json:"status"`
	Charged int    `json:"charged_credits"`
	Output  struct{ Output string `json:"output"` } `json:"output"`
}
var job Job
key := "voice-translator:translate:example-1" // any stable hash of the exact body
if err := call("POST", "/run", segment, map[string]string{"Idempotency-Key": key}, &job); err != nil { panic(err) }
for job.Status != "succeeded" && job.Status != "failed" {
	time.Sleep(time.Second)
	if err := call("GET", "/jobs/"+job.JobID, nil, nil, &job); err != nil { panic(err) }
}
t := job.Output.Output // import "strings"
var out struct{ Translation, To, Detected, Back, Note string }
json.Unmarshal([]byte(t[strings.Index(t, "{"):strings.LastIndex(t, "}")+1]), &out)
fmt.Println(out.Translation, "|", out.Back, "|", job.Charged, "credits")
String key = "voice-translator:translate:example-1"; // any stable hash of the exact body
JsonNode job = api("POST", "/run", segment, Map.of("Idempotency-Key", key));
while (!job.path("status").asText().matches("succeeded|failed")) {
    Thread.sleep(1000);
    job = api("GET", "/jobs/" + job.get("job_id").asText(), null, Map.of());
}
String t = job.path("output").path("output").asText();
JsonNode out = JSON.readTree(t.substring(t.indexOf('{'), t.lastIndexOf('}') + 1));
System.out.println(out.get("translation").asText() + " | " + job.get("charged_credits") + " credits");
require "digest"

key = "voice-translator:translate:#{Digest::SHA1.hexdigest(segment.to_json)[0, 16]}:1"
job = api("POST", "/run", segment, { "Idempotency-Key" => key })
until %w[succeeded failed].include?(job["status"])
  sleep 1
  job = api("GET", "/jobs/#{job['job_id']}")
end
t = job.dig("output", "output").to_s
out = JSON.parse(t[t.index("{")..t.rindex("}")])
puts "#{out['translation']} | #{out['back']} | #{job['charged_credits']} credits"
$key = "voice-translator:translate:" . substr(sha1(json_encode($segment)), 0, 16) . ":1";
$job = api("POST", "/run", $segment, ["Idempotency-Key" => $key]);
while (!in_array($job["status"] ?? "", ["succeeded", "failed"])) {
    sleep(1);
    $job = api("GET", "/jobs/" . $job["job_id"]);
}
$t = $job["output"]["output"] ?? "";
$out = json_decode(substr($t, strpos($t, "{"), strrpos($t, "}") - strpos($t, "{") + 1), true);
echo $out["translation"], " | ", $out["back"], " | ", $job["charged_credits"], " credits", PHP_EOL;
var key = "voice-translator:translate:example-1"; // any stable hash of the exact body
var job = await Translator.ApiAsync(HttpMethod.Post, "/run", segment, new() { ["Idempotency-Key"] = key });
while (job.GetProperty("status").GetString() is not ("succeeded" or "failed"))
{
    await Task.Delay(1000);
    job = await Translator.ApiAsync(HttpMethod.Get, "/jobs/" + job.GetProperty("job_id").GetString());
}
var t = job.GetProperty("output").GetProperty("output").GetString()!;
var o = JsonDocument.Parse(t[t.IndexOf('{')..(t.LastIndexOf('}') + 1)]).RootElement;
Console.WriteLine($"{o.GetProperty("translation")} | {job.GetProperty("charged_credits")} credits");

Step 5 - Stream the next segment, with context

POST /run-stream

The next segment carries the previous one in context, so "it" and "there" land on the right words. Over Server-Sent Events you get a job event, tick heartbeats, then one done event carrying the whole job. Read the reply from done. An idempotent replay answers with plain JSON instead of a stream, so check the content type.

BODY=$(jq -c '. + {text: "and is it far from here can I walk", numbers: "",
  context: "A (en): um so the train leaves at 7:30 from platform 4 where do I buy a ticket => Bueno, el tren sale a las 7:30 del andén 4. ¿Dónde compro un billete?"}' segment.json)
KEY="voice-translator:translate:$(printf %s "$BODY" | shasum | cut -c1-16):1"
curl -N -s -X POST "$API/run-stream" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" -d "$BODY" > stream.txt
sed -n '/^event: done/{n;s/^data: *//p;}' stream.txt | jq -r '(.job // .) | .output.output' | jq .translation
def run_stream(body, key):
    h = {"Content-Type": "application/json", "Authorization": f"Bearer {TOKEN}", "Idempotency-Key": key}
    res = requests.post(API + "/run-stream", json=body, headers=h, stream=True, timeout=300)
    if "text/event-stream" not in res.headers.get("content-type", ""):
        return res.json()["data"]
    event = None
    for line in res.iter_lines(decode_unicode=True):
        if line.startswith("event:"):
            event = line[6:].strip()
        elif line.startswith("data:") and event in ("done", "error"):
            data = json.loads(line[5:])
            if event == "error":
                raise RuntimeError(data)
            return data.get("job", data)
    raise RuntimeError("stream ended without a done event")

nxt = dict(segment, text="and is it far from here can I walk", numbers="",
           context="A (en): " + segment["text"] + " => " + out["translation"])
done = run_stream(nxt, "voice-translator:translate:seg2:1")
print(reply(done["output"]["output"])["translation"])
async function runStream(body, key) {
  const res = await fetch(API + "/run-stream", {
    method: "POST",
    headers: { "Content-Type": "application/json", Authorization: `Bearer ${TOKEN}`, "Idempotency-Key": key },
    body: JSON.stringify(body),
  });
  if (!(res.headers.get("content-type") || "").includes("text/event-stream")) return (await res.json()).data;
  let event = "";
  for (const line of (await res.text()).split("\n")) {
    if (line.startsWith("event:")) event = line.slice(6).trim();
    else if (line.startsWith("data:") && (event === "done" || event === "error")) {
      const data = JSON.parse(line.slice(5));
      if (event === "error") throw new Error(JSON.stringify(data));
      return data.job || data;
    }
  }
  throw new Error("stream ended without a done event");
}
const next = { ...segment, text: "and is it far from here can I walk", numbers: "",
  context: `A (en): ${segment.text} => ${out.translation}` };
const done = await runStream(next, "voice-translator:translate:seg2:1");
console.log(reply(done.output.output).translation);
// Same request as step 4 against /run-stream; read lines with bufio.Scanner and keep the
// data: line that follows "event: done".
segment["text"] = "and is it far from here can I walk"
segment["numbers"] = ""
segment["context"] = "A (en): ... => " + out.Translation
bodyBytes, _ := json.Marshal(segment)
req, _ := http.NewRequest("POST", API+"/run-stream", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "voice-translator:translate:seg2:1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<22)
event := ""
for sc.Scan() {
	l := sc.Text()
	if strings.HasPrefix(l, "event:") { event = strings.TrimSpace(l[6:]) }
	if strings.HasPrefix(l, "data:") && event == "done" { fmt.Println(l[5:]); break }
}
segment.put("text", "and is it far from here can I walk").put("numbers", "")
       .put("context", "A (en): ... => " + out.get("translation").asText());
var req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
    .header("Content-Type", "application/json").header("Authorization", "Bearer " + TOKEN)
    .header("Idempotency-Key", "voice-translator:translate:seg2:1")
    .POST(HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(segment)))
    .build();
String event = "";
for (String line : HTTP.send(req, HttpResponse.BodyHandlers.ofLines()).body().toList()) {
    if (line.startsWith("event:")) event = line.substring(6).trim();
    else if (line.startsWith("data:") && event.equals("done")) { System.out.println(line.substring(5)); break; }
}
nxt = segment.merge(text: "and is it far from here can I walk", numbers: "",
                    context: "A (en): #{segment[:text]} => #{out['translation']}")
uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
                          "Idempotency-Key" => "voice-translator:translate:seg2:1")
req.body = nxt.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 300) { |h| h.request(req) }
event = nil
res.body.each_line do |line|
  event = line[6..].strip if line.start_with?("event:")
  puts line[5..] and break if line.start_with?("data:") && event == "done"
end
$next = ["text" => "and is it far from here can I walk", "numbers" => "",
    "context" => "A (en): " . $segment["text"] . " => " . $out["translation"]] + $segment;
$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 300,
    CURLOPT_HTTPHEADER => ["Content-Type: application/json", "Authorization: Bearer " . TOKEN,
        "Idempotency-Key: voice-translator:translate:seg2:1"],
    CURLOPT_POSTFIELDS => json_encode($next)]);
$event = "";
foreach (explode("\n", curl_exec($ch)) as $line) {
    if (str_starts_with($line, "event:")) $event = trim(substr($line, 6));
    elseif (str_starts_with($line, "data:") && $event === "done") { echo substr($line, 5), PHP_EOL; break; }
}
segment["text"] = "and is it far from here can I walk";
segment["numbers"] = "";
segment["context"] = "A (en): ... => " + o.GetProperty("translation").GetString();
var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream")
    { Content = JsonContent.Create(segment) };
req.Headers.Add("Authorization", "Bearer " + Translator.Token);
req.Headers.Add("Idempotency-Key", "voice-translator:translate:seg2:1");
using var http = new HttpClient();
var res = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
string? line, ev = "";
while ((line = await reader.ReadLineAsync()) != null)
{
    if (line.StartsWith("event:")) ev = line[6..].Trim();
    else if (line.StartsWith("data:") && ev == "done") { Console.WriteLine(line[5..]); break; }
}

In conversation mode, person B's turns go the other way: send B's speech with speaker: "B" and the source and target swapped. The browser app also reads every translation aloud on the listener's device; from the API you get the text, so feed translation to any text-to-speech engine for the voice.

Step 6 - Start from a recording: Whisper on SkillSafe

POST /files, then POST /run-stream with $model: "whisper"

The app's Accurate listening mode does this for every spoken segment: one clip (the app sends 16 kHz mono WAV, cut at a pause) is uploaded, transcribed by Whisper large-v3 turbo, and the transcript becomes the text of a normal translate run (steps 3 to 5). A transcription run takes exactly one audio file in $files (WAV, MP3, OGG, WebM, FLAC, M4A or AAC, up to 10 MB) and no other fields. The reply's output.output is the plain transcript, without timestamps. It is billed per second of audio: about $0.0001 for 10 s, and /estimate on the same body shows the fixed amount reserved per clip.

# 1. upload the clip (keep the file id)
FILE=$(curl -s "$API/files" -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -F "file=@clip.wav;type=audio/wav" | jq -r '.data.file.file_id')

# 2. transcribe it - Idempotency-Key makes a retry replay instead of billing twice
curl -sN "$API/run-stream" -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" -H "Idempotency-Key: voice-translator:stt:clip1:1" \
  -d "{\"\$model\":\"whisper\",\"\$files\":[\"$FILE\"]}"
# event: done  data: {"status":"succeeded","charged_credits":2,"output":{"output":"where is the train station"}}

# 3. delete the clip once transcribed
curl -s -X DELETE "$API/files/$FILE" -H "Authorization: Bearer $SKILLSAFE_TOKEN"

Then send the transcript as text with task: "translate". Whisper detects the spoken language itself; the translate run checks it against source_lang.