Text embeddings

Pay 100 zat to turn up to about 1,000 tokens of text into vectors, in the OpenAI embeddings shape, for search and retrieval.

On this page

Text embeddings turn text into vectors that an agent can compare, store and search. One paid call embeds up to about 1,000 tokens, in a single string or in many, and costs 100 zat.

The service asks any OpenAI-compatible embeddings API (Ollama by default) and answers in the OpenAI shape, so code written for that API reads the answer unchanged.

At a glance

PropertyValue
Paid routePOST /v1/embeddings
Price100 zat per call (Z402_PRICE_ZAT replaces it)
One paid call buysup to about 1,000 tokens of text, at most 4,000 characters and 64 strings
Free routeGET /health
Default port4033
Providerthe operator
Ready whenthe model provider lists EMBEDDINGS_MODEL
Listed in the Bazaar as"Text embeddings", tags embeddings, vectors, rag

How it works

POST input402 and the pricePOST input with a voucherVerifyValidEmbedVectorsSettle200 vectorsCheck the sizeAgentEmbeddings serviceFacilitatorModelAgentEmbeddings serviceFacilitatorModel
POSTinput402 andthe pricePOSTinput witha voucherVerifyValidEmbedVectorsSettle200vectorsCheckthesizeAgentEmbeddingsserviceFacilitatorModelAgentEmbeddingsserviceFacilitatorModel

The service checks the whole body before it asks for money. A body that is invalid or too long is refused on the unpaid attempt, so a call is never failed for being long after you paid. After payment the only way a call fails is the provider failing.

What a call buys

The unit is "about 1,000 tokens". The service does not count tokens before payment. It limits characters, which it can count exactly, and 4,000 characters is about 1,000 tokens of English text. Text that tokenizes less efficiently, such as code or some languages, can hold more tokens in the same characters.

Characters are counted in Unicode code points, so an emoji counts once. The total is summed over all the strings in the call. To embed more text, make more calls.

The route

POST/v1/embeddings

Embeds one string or a list of strings. Paid: 100 zat per call. An unpaid call that passes the checks gets a 402 with the offer in the PAYMENT-REQUIRED header. Send Content-Type: application/json.

inputstring or string[]Required
One string, or 1 to 64 strings (EMBEDDINGS_MAX_INPUTS). No string may be blank or hold an unpaired surrogate. All strings together hold at most 4,000 characters (EMBEDDINGS_MAX_CHARS). One string may hold at most EMBEDDINGS_MAX_INPUT_CHARS, which is unlimited by default and 2,000 under Docker.

The answer:

FieldTypeMeaning
objectstringAlways list.
modelstringThe model that embedded the text (EMBEDDINGS_MODEL).
dimensionsintegerThe length of each vector.
dataarrayOne entry per input: object (embedding), index (the input's position) and embedding (the vector).
usageobjectprompt_tokens and total_tokens, as the provider reports them.
usageEstimatedbooleanPresent, and true, only when the provider reported no usage. The service then estimates about 4 characters a token, at least 1 for each input.

dimensions and usageEstimated are additions to the OpenAI shape. Every vector has the same length and a distinct index, and the service refuses any provider answer that does not.

curl -si http://127.0.0.1:4033/v1/embeddings \
  -H 'content-type: application/json' \
  -d '{"input":"The quick brown fox"}'

The paid answer above is abridged. A real vector lists all dimensions numbers.

Price and billing

A call costs 100 zat however many strings it holds, up to the limits. Five short strings in one call cost the same as one. Batch what you can: a call at the cap of 4,000 characters costs 100 zat, and the same text sent in 64 calls costs 6,400.

Limits and refusals

Every refusal below is answered on the unpaid first attempt. No voucherA message the agent signs for each call. It states the agent's running total, so the facilitator can check it against the deposit. is sent and nothing is counted against you.

StatuserrorWhen
400invalid_requestThe body is not JSON, or input is missing, is not a string or a list of strings, holds no strings or too many, or has a blank string or one with an unpaired surrogate. message says which.
405method_not_allowedAny method but POST. The Allow header says POST.
409payment_in_flightThe same voucher is already paying for a call.
413too_many_tokensThe strings hold more than EMBEDDINGS_MAX_CHARS characters in total, or one string holds more than EMBEDDINGS_MAX_INPUT_CHARS, or the body is over its size limit. The body carries maxChars and, when it is lower, maxInputChars.
503not_readyThe provider is down or does not list the model. Checked about every 10 seconds.

The body size limit is 256 KiB, or 12 bytes for each character of EMBEDDINGS_MAX_CHARS plus room for the strings when that is more. It is what a call at the cap weighs when a client writes every character as an escape.

Failures after payment

A call fails after payment only when the provider does: 502 provider_failed. The facilitator does not settle it, but the stock client cannot tell a failed call from a paid one, so it counts the call and its next voucher pays for it. That is why not_ready is checked first.

The provider is trusted to be up and not to be right. The service reads its answer up to 1 MiB plus 256 KiB for each input, reads its model list up to 8 MiB, and does not read the body of an error answer at all. A list that does not hold one finite vector of the same length for each input, each with its own index, is refused as a provider failure.

Make a paid call

This example ranks three documents against a query with cosine similarity. The four strings go in one call, so the whole search costs 100 zat. It needs a funded wallet and a running service: Make a paid call names the stack that paid-fetch.ts reaches, and Pay from an agent explains the setup.

import { close, paidFetch } from "./paid-fetch.js";

interface Embeddings {
  model: string;
  dimensions: number;
  data: { index: number; embedding: number[] }[];
  usage: { prompt_tokens: number; total_tokens: number };
}

async function embed(input: string[]): Promise<number[][]> {
  const res = await paidFetch("http://127.0.0.1:4033/v1/embeddings", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ input }),
  });
  if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
  const body = (await res.json()) as Embeddings;
  return [...body.data].sort((a, b) => a.index - b.index).map((d) => d.embedding);
}

function cosine(a: number[], b: number[]): number {
  const dot = a.reduce((sum, x, i) => sum + x * (b[i] ?? 0), 0);
  const norm = (v: number[]): number => Math.sqrt(v.reduce((sum, x) => sum + x * x, 0));
  return dot / (norm(a) * norm(b));
}

const query = "private payments for software agents";
const docs = [
  "How to file quarterly taxes",
  "Shielded payments hide who pays whom",
  "A recipe for sourdough bread",
];

try {
  const [queryVector, ...docVectors] = await embed([query, ...docs]);
  if (queryVector === undefined) throw new Error("no vectors came back");
  const ranked = docs
    .map((text, i) => ({ text, score: cosine(queryVector, docVectors[i] ?? []) }))
    .sort((a, b) => b.score - a.score);
  console.log(ranked);
} finally {
  await close();
}

Privacy

  • The operator and its provider see the text you send and the vectors that come back.
  • The service keeps no copy of your text in its own code. A hosted provider may keep one, so read its policy before you send sensitive text.
  • The facilitator sees the payment, not the request: the voucher, the price and the route's public URL.

See Privacy model for what the payment itself reveals.

Run it yourself

Bash
ollama pull nomic-embed-text
cp services/embeddings/.env.example services/embeddings/.env   # set the facilitator URL and key, and Z402_PAY_TO
pnpm --filter @z402/service-embeddings start

The ready line goes to stdout. Logs go to stderr.

Text
embeddings listening on http://127.0.0.1:4033 network=zcash:regtest model=nomic-embed-text provider=http://127.0.0.1:11434

An empty variable counts as unset, and a bad one stops the start with a line that names it.

Settings

The variables every service shares (Z402_NETWORK, Z402_FACILITATOR_URL, Z402_FACILITATOR_TOKEN, Z402_PAY_TO, Z402_PRICE_ZAT, PUBLIC_URL, HOST, PORT) are listed under LLM inference. Here Z402_PRICE_ZAT defaults to 100 and PORT to 4033. Its own:

VariableDefaultMeaning
EMBEDDINGS_BASE_URLhttp://127.0.0.1:11434/v1The provider's OpenAI-style base URL, without credentials.
EMBEDDINGS_API_KEY or _FILEnoneSent as a bearer token.
EMBEDDINGS_MODELnomic-embed-textThe service is ready only while the provider lists it. A name and name:latest are one model.
EMBEDDINGS_MAX_CHARS4000Most characters in one call, summed over all inputs, 1 to 60,000. Keep it within the model's context, since a provider that rejects a long input can only do so after payment.
EMBEDDINGS_MAX_INPUT_CHARSunlimitedMost characters in one string, 1 to 60,000. A local model with a short context needs it.
EMBEDDINGS_MAX_INPUTS64Most strings in one call, 1 to 2,048.

Under Docker, compose reads docker/env/embeddings.env first, then services/embeddings/.env, which wins. The Docker defaults point at the bundled llama.cpp server with the nomic-embed-text model and cap one string at 2,000 characters, because that model takes 2,048 tokens per string and a character can be a whole token. With a hosted provider, put an empty EMBEDDINGS_MAX_INPUT_CHARS= in services/embeddings/.env to lift the cap:

services/embeddings/.env
EMBEDDINGS_BASE_URL=https://api.openai.com/v1
EMBEDDINGS_API_KEY=your-provider-key
EMBEDDINGS_MODEL=text-embedding-3-small
EMBEDDINGS_MAX_INPUT_CHARS=

See Deploy with Docker.

Readiness

GET /health answers 200 when the provider lists the model and 503 when it does not. The service lists its route in the Bazaar only while it is ready. See Discovery.

Next steps

References

  • services/embeddings/src/service.ts: the route, the checks before payment and the paid handler
  • services/embeddings/src/provider.ts: the OpenAI-compatible client and the checks on its answer
  • services/embeddings/src/config.ts: the settings and their limits
  • services/embeddings/README.md: the route, refusals and settings
  • services/embeddings/.env.example: every variable with its default
  • packages/service-kit/src/service.ts: the kit that adds the 402, /health and the Bazaar declarations
  • docs/superpowers/specs/2026-10-27-z402-services-design.md: section 4.3, the service's contract
  • docker/env/embeddings.env: the Docker defaults
  • Ollama: OpenAI compatibility
  • nomic-embed-text v1.5 (GGUF)