Most genes are pure functions — they take input, compute, and return output. But real-world agents need to talk to external services: LLM providers, weather APIs, databases, search engines. Rotifer handles this through Hybrid fidelity, which gives a gene controlled network access via a Network Gateway — a sandboxed fetch proxy with domain whitelisting, rate limiting, timeout enforcement, and response size caps.
This tutorial walks through building a production-quality Hybrid gene from scratch.
When to Use Hybrid
| Fidelity | Network Access | Use Case |
|---|---|---|
| Wrapped | None | Pure logic — text transforms, math, formatting |
| Hybrid | Gateway-controlled | External API calls — LLMs, weather, search, databases |
| Native | None | CPU-bound computation — compiled WASM, crypto, parsing |
Choose Hybrid when your gene needs to reach the outside world. The Network Gateway ensures it can only reach domains you explicitly allow, at rates you define.
Step 1: Initialize the Gene
rotifer init weather-gene --fidelity HybridThis scaffolds a gene directory with a phenotype.json pre-configured for Hybrid fidelity:
{ "name": "weather-gene", "domain": "utility", "description": "", "inputSchema": { "type": "object", "properties": { "prompt": { "type": "string" } }, "required": [] }, "outputSchema": { "type": "object", "properties": { "result": { "type": "string" } } }, "dependencies": [], "version": "0.1.0", "fidelity": "Hybrid", "transparency": "Open", "network": { "allowedDomains": [], "maxTimeoutMs": 30000, "maxResponseBytes": 1048576, "maxRequestsPerMin": 10 }}The network block is what distinguishes Hybrid from other fidelity levels. Every field matters — the runtime enforces all of them.
Step 2: Configure Network Access
Edit phenotype.json to declare the domains your gene needs:
{ "network": { "allowedDomains": ["wttr.in"], "maxTimeoutMs": 10000, "maxResponseBytes": 524288, "maxRequestsPerMin": 5 }}Domain rules:
- Exact match —
"wttr.in"allows onlywttr.in, not subdomains. - Wildcard —
"*.supabase.co"matchesabc123.supabase.coandsupabase.coitself. - Forbidden —
localhost,127.0.0.1,192.168.*,10.*, and other private/loopback addresses are always rejected by the publish pipeline. They pass local tests but will be blocked on publish.
Keep the domain list minimal. Every domain you add is an attack surface your gene exposes. If your gene calls one API, list one domain.
Tuning the limits:
| Field | Default | Guidance |
|---|---|---|
maxTimeoutMs | 30000 | Set to 2–3× your expected API latency. 10s for fast APIs, 30s for LLMs. |
maxResponseBytes | 1048576 (1 MiB) | Enough for most JSON APIs. Increase for large payloads, decrease for simple endpoints. |
maxRequestsPerMin | 10 | Match your upstream rate limit. Don’t set higher than the provider allows. |
Step 3: Write the Express Function
Create express.ts in the gene directory. Hybrid genes receive a ctx object with a gatewayFetch function — this is your only network interface. Global fetch is not available inside the gene sandbox.
import type { GatewayFetchOptions, GatewayResponse } from "@rotifer/core";
interface WeatherInput { city: string; format?: "brief" | "detailed";}
interface WeatherOutput { result: string; city: string; source: string;}
export default async function express( input: WeatherInput, ctx: { gatewayFetch: (url: string, options?: GatewayFetchOptions) => Promise<GatewayResponse> }): Promise<WeatherOutput> { const city = encodeURIComponent(input.city); const formatParam = input.format === "detailed" ? "" : "?format=3"; const url = `https://wttr.in/${city}${formatParam}`;
const response = await ctx.gatewayFetch(url, { method: "GET", headers: { "Accept": "text/plain" }, });
if (response.status !== 200) { throw new Error(`Weather API returned ${response.status}: ${response.body}`); }
return { result: response.body.trim(), city: input.city, source: "wttr.in", };}Key points:
ctx.gatewayFetch, notfetch— this is the gateway-proxied version that respects your domain whitelist, rate limit, and timeout.- Input validation happens before the call — encode user-supplied strings, validate formats.
- Return structured output — your gene’s consumers (agents or other genes) expect typed data, not raw API responses.
Step 4: Handle Gateway Errors
The Network Gateway throws NetworkGatewayError with specific codes. Production genes must handle each one gracefully:
import type { GatewayFetchOptions, GatewayResponse } from "@rotifer/core";
interface WeatherInput { city: string;}
interface WeatherOutput { result: string; city: string; source: string; degraded: boolean;}
export default async function express( input: WeatherInput, ctx: { gatewayFetch: (url: string, options?: GatewayFetchOptions) => Promise<GatewayResponse> }): Promise<WeatherOutput> { const city = encodeURIComponent(input.city);
try { const response = await ctx.gatewayFetch(`https://wttr.in/${city}?format=3`, { method: "GET", headers: { "Accept": "text/plain" }, });
return { result: response.body.trim(), city: input.city, source: "wttr.in", degraded: response.truncated, }; } catch (err: any) { switch (err.code) { case "DOMAIN_BLOCKED": throw new Error(`Configuration error: wttr.in not in allowedDomains`);
case "RATE_LIMITED": return { result: "Weather service temporarily unavailable (rate limited). Try again shortly.", city: input.city, source: "fallback", degraded: true, };
case "TIMEOUT": return { result: "Weather service did not respond in time. Try again later.", city: input.city, source: "fallback", degraded: true, };
case "RESPONSE_TOO_LARGE": return { result: "Weather response exceeded size limit. Try brief format.", city: input.city, source: "fallback", degraded: true, };
case "NETWORK_ERROR": return { result: `Network error: ${err.message}`, city: input.city, source: "fallback", degraded: true, };
default: throw err; } }}The pattern: throw on configuration errors (DOMAIN_BLOCKED means the phenotype is misconfigured), degrade gracefully on transient errors (rate limits, timeouts, network failures). The degraded flag lets downstream consumers know the result may be incomplete.
Step 5: Test and Compile
Run the gene’s test suite:
rotifer test weather-geneExpected output:
✓ weather-gene/express — city:"London" → result contains "London" ✓ weather-gene/express — city:"東京" → result contains "東京" ✓ weather-gene/error — invalid city → graceful fallback
3 passed | 0 failed Next: rotifer compile weather-geneThen compile to IR:
rotifer compile weather-gene Compiling weather-gene v0.1.0 Fidelity: Hybrid Network: wttr.in (1 domain) Timeout: 10000ms | Max body: 512 KiB | Rate: 5/min Output: genes/weather-gene/weather-gene.wasm (42 KiB)
✓ Compiled successfullyThe compiler embeds the network configuration into the WASM custom sections. The runtime reads this at load time — there’s no way for the gene to bypass its declared network policy.
Step 6: Publish and Run in an Agent
Publish to the Rotifer Cloud registry:
rotifer publish weather-geneNow use it in an agent:
rotifer agent create my-weather-agent --genes weather-generotifer agent run my-weather-agent --input '{"city": "Berlin"}' Agent: my-weather-agent Gene: weather-gene (Hybrid, gateway: wttr.in)
Result: Berlin: ⛅ +12°CThe runtime auto-detects Hybrid fidelity, reads the network config from the compiled IR, constructs a NetworkGateway instance with the declared limits, and injects gatewayFetch into the gene’s execution context. The gene never touches raw fetch.
Best Practices
Minimal domain surface. List only the domains your gene actually calls. One gene, one API, one domain is the ideal. If you need multiple APIs, consider splitting into separate genes that compose in an agent.
Environment variables for API keys. Never hardcode credentials in gene source. Use ctx.env for API keys that the agent operator provides at deploy time. The gene declares what it needs; the operator supplies values.
Reasonable limits. Set maxTimeoutMs to 2–3× your expected latency, not the maximum 30s. Set maxResponseBytes to the actual payload size you expect, not 1 MiB by default. Tight limits catch regressions early.
LLM provider agnosticism. If your gene wraps an LLM, accept the endpoint URL and model name as input parameters. Don’t hardcode api.openai.com — let the operator choose their provider. Declare "allowedDomains": ["*.openai.com", "*.anthropic.com", "*.mistral.ai"] or accept the domain as a runtime parameter.
Test both happy and error paths. Every gatewayFetch call can fail five ways. Your test suite should cover at minimum: successful response, rate-limited response, and timeout. Use the NetworkGateway class directly in tests to simulate each scenario.
Deep Dive: See the full Hybrid Gene Development Guide for Network Gateway reference and RAG pipeline examples.