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.
When to Use Hybrid
Section titled “When to Use Hybrid”| Fidelity | Network | Use Case |
|---|---|---|
| Wrapped | None | Pure data transformation, text processing |
| Hybrid | Gateway-controlled | LLM APIs, database queries, embeddings |
| Native | None | CPU-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.
Step 1: Initialize a Hybrid Project
Section titled “Step 1: Initialize a Hybrid Project”rotifer init my-hybrid-gene --fidelity HybridThis 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:
rotifer wrap my-gene --fidelity Hybrid --domain text.analyzeStep 2: Configure Network Access
Section titled “Step 2: Configure Network Access”Edit phenotype.json to declare the exact domains your gene needs:
{ "network": { "allowedDomains": ["api.openai.com", "*.supabase.co"], "maxTimeoutMs": 30000, "maxResponseBytes": 1048576, "maxRequestsPerMin": 10 }}Network Configuration Reference
Section titled “Network Configuration Reference”| Parameter | Type | Default | Description |
|---|---|---|---|
allowedDomains | string[] | [] | Domains the gene may call. Supports exact match and *.domain.com wildcards |
maxTimeoutMs | number | 30000 | Per-request timeout in milliseconds |
maxResponseBytes | number | 1048576 | Maximum response body size (1 MiB default) |
maxRequestsPerMin | number | 10 | Rate limit — requests per 60-second sliding window |
Domain Rules
Section titled “Domain Rules”- Exact match:
api.openai.comallows onlyapi.openai.com - Wildcard:
*.supabase.coallowsabc.supabase.coandsupabase.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
Step 3: Write the Express Function
Section titled “Step 3: Write the Express Function”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 };}Gateway Behavior
Section titled “Gateway Behavior”When your gene calls gatewayFetch:
- URL check — the URL must use https and name a host, not an IP address; otherwise an
INVALID_REQUESTerror is thrown. Then the hostname is validated againstallowedDomains. If blocked, aDOMAIN_BLOCKEDerror is thrown immediately. - Rate check — if the gene has exceeded
maxRequestsPerMinin the current 60-second window, aRATE_LIMITEDerror is thrown. - Timeout — the request is aborted after
maxTimeoutMswith aTIMEOUTerror. - Response cap — if the response body exceeds
maxResponseBytes, it is truncated and atruncated: trueflag is set. - 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.
Gateway Error Codes
Section titled “Gateway Error Codes”| Code | Meaning |
|---|---|
INVALID_REQUEST | Not an https URL, or the host is an IP address |
DOMAIN_BLOCKED | URL hostname (or a redirect hop’s) not in allowedDomains |
RATE_LIMITED | Exceeded maxRequestsPerMin |
TIMEOUT | Request exceeded maxTimeoutMs |
RESPONSE_TOO_LARGE | Response body exceeded maxResponseBytes |
NETWORK_ERROR | Other fetch failure |
Step 4: Compile
Section titled “Step 4: Compile”rotifer compile my-hybrid-geneFor 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...Step 5: Publish
Section titled “Step 5: Publish”rotifer publish my-hybrid-geneThe publish command runs two Hybrid-specific validations before authenticating:
E0055 — Missing Allowed Domains
Section titled “E0055 — Missing Allowed Domains”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.jsonE0056 — Forbidden Domains
Section titled “E0056 — Forbidden Domains”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 publishingStep 6: Run in an Agent
Section titled “Step 6: Run in an Agent”Create an agent that includes your Hybrid gene:
rotifer agent create my-agent --genes my-hybrid-generotifer agent run my-agent --input '{"query": "What is Rotifer?"}'The agent-runtime automatically:
- Detects
fidelity: "Hybrid"in the gene’s phenotype - Creates a
NetworkGatewaywith the gene’snetworkconfiguration - Injects
gatewayFetchinto theexpress()call - 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 anetworkblock gets network access. A Native or Wrapped gene, or a Hybrid gene without anetworkblock, has none. - No gene gets file-system access.
- Every
rotifer.netrequest is checked before it is sent: HTTPS only, no IP addresses, the host must equal one ofnetwork.allowedDomainsexactly (no implicit subdomains), and the host must also be listed in thedomainsof one of the gene’sexternalDependencies. 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 runstops before the gene runs and names the variable. Only a gene that gets network access (Hybrid, with anetworkblock) 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:
| Code | Meaning |
|---|---|
-2 | No network permission (not Hybrid, or no network block), or a header uses ${env:NAME} for a name the gene does not declare in credentials |
-3 | Host is not in allowedDomains |
-4 | The request timed out, or the run’s deadline has passed |
-5 | Network error (the request could not be completed) |
-6 | The response is larger than maxResponseBytes |
-7 | The run’s request limit is reached |
-10 | Malformed request, a non-HTTPS URL, or an IP address |
-11 | Host is not declared in any externalDependencies entry |
Real-World Example: RAG Pipeline
Section titled “Real-World Example: RAG Pipeline”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:
| Gene | Allowed Domains |
|---|---|
doc-retrieval | api.openai.com, *.supabase.co |
answer-synthesizer | api.anthropic.com, api.openai.com |
Best Practices
Section titled “Best Practices”Minimal Domain Surface
Section titled “Minimal Domain Surface”Only declare the domains your gene actually calls. Each domain in allowedDomains is a trust surface — keep it small.
"allowedDomains": ["api.openai.com"]Graceful Degradation
Section titled “Graceful Degradation”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;}Environment Variables for API Keys
Section titled “Environment Variables for API Keys”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");LLM Provider Agnosticism
Section titled “LLM Provider Agnosticism”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";Reasonable Limits
Section titled “Reasonable Limits”Tune maxTimeoutMs and maxResponseBytes for your use case:
| Use Case | Timeout | Response Size |
|---|---|---|
| LLM chat completion | 30s | 1 MiB |
| Embedding API | 10s | 256 KiB |
| Database query | 5s | 512 KiB |