Skip to content

Hybrid Gene Development Guide

Hybrid genes extend the Rotifer safety model to support controlled network access. Unlike Wrapped genes (pure functions) or Native genes (WASM-only), Hybrid genes can call external APIs — but only through a Network Gateway that enforces domain whitelisting, rate limiting, timeouts, and response size caps.

This guide walks you through creating, testing, and publishing a Hybrid gene.

FidelityNetworkUse Case
WrappedNonePure data transformation, text processing
HybridGateway-controlledLLM APIs, database queries, embeddings
NativeNoneCPU-intensive WASM computation

Use Hybrid when your gene needs to call an external service (OpenAI, Anthropic, Supabase, etc.) but you still want the protocol’s safety guarantees.


Terminal window
rotifer init my-hybrid-gene --fidelity Hybrid

This creates a project with a pre-configured phenotype.json that includes the network block:

{
"name": "my-hybrid-gene",
"domain": "general",
"fidelity": "Hybrid",
"version": "0.1.0",
"inputSchema": { "type": "object" },
"outputSchema": { "type": "object" },
"network": {
"allowedDomains": ["api.example.com"],
"maxTimeoutMs": 30000,
"maxResponseBytes": 1048576,
"maxRequestsPerMin": 10
}
}

You can also convert an existing gene to Hybrid:

Terminal window
rotifer wrap my-gene --fidelity Hybrid --domain text.analyze

Edit phenotype.json to declare the exact domains your gene needs:

{
"network": {
"allowedDomains": ["api.openai.com", "*.supabase.co"],
"maxTimeoutMs": 30000,
"maxResponseBytes": 1048576,
"maxRequestsPerMin": 10
}
}
ParameterTypeDefaultDescription
allowedDomainsstring[][]Domains the gene may call. Supports exact match and *.domain.com wildcards
maxTimeoutMsnumber30000Per-request timeout in milliseconds
maxResponseBytesnumber1048576Maximum response body size (1 MiB default)
maxRequestsPerMinnumber10Rate limit — requests per 60-second sliding window
  • Exact match: api.openai.com allows only api.openai.com
  • Wildcard: *.supabase.co allows abc.supabase.co and supabase.co
  • Forbidden domains (rejected at publish):
    • localhost, 127.x.x.x, 0.0.0.0, ::1
    • Private IPs: 10.x.x.x, 172.16–31.x.x, 192.168.x.x

Hybrid genes receive a gatewayFetch function via the second argument. Use it instead of the global fetch:

export async function express(
input: { query: string },
ctx?: { gatewayFetch?: typeof fetch }
): Promise<{ answer: string }> {
if (!ctx?.gatewayFetch) {
throw new Error("gatewayFetch is required for Hybrid genes");
}
const response = await ctx.gatewayFetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${process.env.OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: "gpt-4o-mini",
messages: [{ role: "user", content: input.query }],
}),
});
const data = await response.json();
return { answer: data.choices[0].message.content };
}

When your gene calls gatewayFetch:

  1. URL check — the URL must use https and name a host, not an IP address; otherwise an INVALID_REQUEST error is thrown. Then the hostname is validated against allowedDomains. If blocked, a DOMAIN_BLOCKED error is thrown immediately.
  2. Rate check — if the gene has exceeded maxRequestsPerMin in the current 60-second window, a RATE_LIMITED error is thrown.
  3. Timeout — the request is aborted after maxTimeoutMs with a TIMEOUT error.
  4. Response cap — if the response body exceeds maxResponseBytes, it is truncated and a truncated: true flag is set.
  5. Redirects — followed by the gateway, at most 5, and each hop goes through the URL check again before it is requested. A redirect to another origin drops your request headers, and a 301, 302 or 303 turns the request into a GET without a body. More than 5 hops is a NETWORK_ERROR.
CodeMeaning
INVALID_REQUESTNot an https URL, or the host is an IP address
DOMAIN_BLOCKEDURL hostname (or a redirect hop’s) not in allowedDomains
RATE_LIMITEDExceeded maxRequestsPerMin
TIMEOUTRequest exceeded maxTimeoutMs
RESPONSE_TOO_LARGEResponse body exceeded maxResponseBytes
NETWORK_ERROROther fetch failure

Terminal window
rotifer compile my-hybrid-gene

For Hybrid genes, the compiler preserves the fidelity: "Hybrid" flag and embeds the network configuration into the IR custom sections. The output shows the allowed domains:

✓ Compiled to Rotifer IR
Fidelity: Hybrid
Network: api.openai.com, *.supabase.co
IR Hash: sha256-abc123...

Terminal window
rotifer publish my-hybrid-gene

The publish command runs two Hybrid-specific validations before authenticating:

If network.allowedDomains is missing or empty:

✗ [E0055] Hybrid gene must declare at least one allowed domain
in network.allowedDomains
Fix: Add "network": { "allowedDomains": ["api.example.com"] }
to phenotype.json

If any domain resolves to localhost or a private IP:

✗ [E0056] Forbidden domain in network.allowedDomains: "localhost"
(localhost/private IP not allowed)
Fix: Remove private/localhost domains before publishing

Create an agent that includes your Hybrid gene:

Terminal window
rotifer agent create my-agent --genes my-hybrid-gene
rotifer agent run my-agent --input '{"query": "What is Rotifer?"}'

The agent-runtime automatically:

  1. Detects fidelity: "Hybrid" in the gene’s phenotype
  2. Creates a NetworkGateway with the gene’s network configuration
  3. Injects gatewayFetch into the express() call
  4. Logs gateway stats with --verbose

What the CLI enforces at run time (since v0.9.3)

Section titled “What the CLI enforces at run time (since v0.9.3)”

When a compiled gene runs in the WASM sandbox, through rotifer run, rotifer agent run or rotifer test, it gets exactly the permissions its phenotype declares and nothing more:

  • Only a gene with fidelity: "Hybrid" and a network block gets network access. A Native or Wrapped gene, or a Hybrid gene without a network block, has none.
  • No gene gets file-system access.
  • Every rotifer.net request is checked before it is sent: HTTPS only, no IP addresses, the host must equal one of network.allowedDomains exactly (no implicit subdomains), and the host must also be listed in the domains of one of the gene’s externalDependencies. Then the request limit for the run and the run’s deadline are checked.
  • Each outbound request adds a surcharge to the run’s resource_cost: a fixed cost per call plus a cost per byte sent and received. Network use therefore shows up in the cost the Arena measures.

A dependency is declared in the phenotype like this:

"externalDependencies": [
{
"apiType": "rest",
"semanticTag": "llm-completion",
"domains": ["api.openai.com"]
}
]

API keys (nonstandard preview, ADR-327; @rotifer/playground 0.31.0 or later). A dependency that needs a key names it in credentials, and the gene refers to it as ${env:NAME} in a request header:

"credentials": ["OPENAI_API_KEY"]
"headers": { "Authorization": "Bearer ${env:OPENAI_API_KEY}" }
  • The CLI reads each declared name from the environment variable of the same name. It reads no other variable, and there is no key file.
  • The value is filled in as the request leaves the sandbox. The gene never sees it, and it is not printed or logged.
  • Each gene gets only the names it declares itself, even inside a composition.
  • If a declared variable is unset or empty, rotifer run stops before the gene runs and names the variable. Only a gene that gets network access (Hybrid, with a network block) is asked for its keys; any other gene could not send them anywhere.

Live Web Search with Tavily is a complete gene built this way.

A refused request comes back to the gene as a negative error code, and rotifer run reports Execution failed: … with that code:

CodeMeaning
-2No network permission (not Hybrid, or no network block), or a header uses ${env:NAME} for a name the gene does not declare in credentials
-3Host is not in allowedDomains
-4The request timed out, or the run’s deadline has passed
-5Network error (the request could not be completed)
-6The response is larger than maxResponseBytes
-7The run’s request limit is reached
-10Malformed request, a non-HTTPS URL, or an IP address
-11Host is not declared in any externalDependencies entry

The Rotifer docs-assistant pipeline demonstrates a 4-gene Hybrid composition:

query-parser (Wrapped)
│
▼
doc-retrieval (Hybrid) → OpenAI Embeddings + Supabase pgvector
│
▼
answer-synthesizer (Hybrid) → Claude / OpenAI LLM
│
▼
source-linker (Wrapped)

Each Hybrid gene declares only the domains it needs:

GeneAllowed Domains
doc-retrievalapi.openai.com, *.supabase.co
answer-synthesizerapi.anthropic.com, api.openai.com

Only declare the domains your gene actually calls. Each domain in allowedDomains is a trust surface — keep it small.

"allowedDomains": ["api.openai.com"]

Handle gateway errors explicitly — don’t let network failures crash your gene:

try {
const res = await ctx.gatewayFetch(url, options);
// ...
} catch (err) {
if (err.code === "RATE_LIMITED") {
return { answer: "Rate limited — please retry.", error: true };
}
throw err;
}

Never hardcode API keys in gene source. In the WASM sandbox, declare the key in credentials and use ${env:NAME} (see What the CLI enforces at run time). On the Node.js path, read the environment variable:

const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) throw new Error("OPENAI_API_KEY not set");

Design genes to support multiple LLM providers via environment variables:

const provider = process.env.ROTIFER_LLM_PROVIDER || "openai";
const endpoint = provider === "claude"
? "https://api.anthropic.com/v1/messages"
: "https://api.openai.com/v1/chat/completions";

Tune maxTimeoutMs and maxResponseBytes for your use case:

Use CaseTimeoutResponse Size
LLM chat completion30s1 MiB
Embedding API10s256 KiB
Database query5s512 KiB