Gleam 1.x Erlang BEAM OTP Actors CULTIVA IA

AgentFlow — Backend Type-Safe
con Gleam / BEAM

Migración del núcleo de orquestación de agentes IA de Node.js a Gleam: concurrencia real, tipos exhaustivos y tolerancia a fallos sin boilerplate.

Cliente: AgentFlow SaaS  ·  Stack destino: Gleam + Wisp + gleam_otp · Fly.io  ·  4 módulos
Arquitectura del servicio
Cliente HTTP / React App
Wisp Router
router.gleam
AgentSupervisor
supervisor.gleam
AgentActor (por cliente)
agent_actor.gleam
Tipos de dominio
agent.gleam
Cada cliente tiene su propio actor BEAM — crash-safe, aislado, sin locks compartidos
1
Tipos de dominio — src/agentflow/agent.gleam
Tipos custom · Pattern matching exhaustivo · Máquina de estados
src/agentflow/agent.gleam
Gleam
// Tipos de dominio de AgentFlow — el compilador garantiza
// que CADA variant se maneja. Sin runtime surprises.

import gleam/option.{type Option, Some, None}

/// Tipo de agente — cada uno tiene capacidades distintas
pub type AgentType {
  ContentWriter          // Redacción y copy
  SeoAnalyzer            // Auditoría y keywords
  AdCreative             // Creatividades publicitarias
  EmailSequence          // Cadenas de drip
  DataExtractor          // Scraping + parsing
  Custom(name: String)    // Agentes personalizados
}

/// Estado del ciclo de vida de un agente
pub type AgentStatus {
  Idle
  Running
  Paused
  Error(message: String)
  Completed
}

pub type Agent {
  Agent(
    id:         String,
    name:       String,
    agent_type: AgentType,
    status:     AgentStatus,
    owner_id:   String,
    credits_used: Int,
    config:     Option(AgentConfig),
  )
}

pub type AgentConfig {
  AgentConfig(
    model:       String,      // "claude-sonnet-4-5", etc.
    temperature: Float,
    max_tokens:  Int,
    system_prompt: Option(String),
  )
}

/// Máquina de estados: no todas las transiciones son válidas.
/// El compilador fuerza a manejar TODOS los casos.
pub fn transition(from: AgentStatus, to: AgentStatus) -> Result(AgentStatus, String) {
  case from, to {
    Idle,     Running   -> Ok(Running)
    Running,  Paused    -> Ok(Paused)
    Running,  Completed -> Ok(Completed)
    Running,  Error(_)  -> Ok(to)
    Paused,   Running   -> Ok(Running)   // resume
    Paused,   Idle      -> Ok(Idle)      // reset
    Error(_), Idle      -> Ok(Idle)      // retry
    same, target if same == target -> Ok(target)
    _, _  -> Error("Transición inválida: " <> status_to_string(from) <> " → " <> status_to_string(to))
  }
}

/// Coste en créditos por tipo de agente — exhaustivo, sin default
pub fn credits_per_run(agent_type: AgentType) -> Int {
  case agent_type {
    ContentWriter   -> 10
    SeoAnalyzer     -> 8
    AdCreative      -> 15
    EmailSequence   -> 12
    DataExtractor   -> 20
    Custom(_)        -> 25
  }
}
2
Router HTTP — src/agentflow/router.gleam
Wisp · Type-safe routing · JSON decode/encode
src/agentflow/router.gleam
Gleam
import gleam/http.{Get, Post, Delete, Patch}
import gleam/json
import gleam/result
import wisp.{type Request, type Response}
import agentflow/agent_actor
import agentflow/json_codec

pub fn handle_request(req: Request, supervisor: agent_actor.Supervisor) -> Response {
  // Middleware: logging + body size limit (1 MB)
  use req <- wisp.log_request(req)
  use req <- wisp.rescue_crashes(req)
  use req <- wisp.max_body_size(req, 1_000_000)

  case wisp.path_segments(req) {
    // GET /api/agents — listar agentes del cliente
    ["api", "agents"] if req.method == Get ->
      list_agents(req, supervisor)

    // POST /api/agents — crear agente
    ["api", "agents"] if req.method == Post ->
      create_agent(req, supervisor)

    // GET /api/agents/:id
    ["api", "agents", id] if req.method == Get ->
      get_agent(req, supervisor, id)

    // PATCH /api/agents/:id/run — ejecutar agente
    ["api", "agents", id, "run"] if req.method == Patch ->
      run_agent(req, supervisor, id)

    // DELETE /api/agents/:id
    ["api", "agents", id] if req.method == Delete ->
      delete_agent(req, supervisor, id)

    // Health check
    ["health"] ->
      wisp.json_response(json.to_string_builder(json.object([
        #("status", json.string("ok")),
        #("vm",     json.string("beam")),
      ])), 200)

    _ -> wisp.not_found()
  }
}

fn create_agent(req: Request, supervisor: agent_actor.Supervisor) -> Response {
  // use-syntax: si falla, devuelve 400 automáticamente
  use body <- wisp.require_json(req)
  use owner_id <- require_header(req, "x-owner-id")

  case json_codec.decode_create_request(body) {
    Ok(create_req) -> {
      case agent_actor.create(supervisor, owner_id, create_req) {
        Ok(agent) ->
          wisp.json_response(
            json.to_string_builder(json_codec.encode_agent(agent)),
            201,
          )
        Error(err) ->
          wisp.json_response(json.to_string_builder(error_json(err)), 422)
      }
    }
    Error(_) -> wisp.bad_request()
  }
}
3
Actor OTP — src/agentflow/agent_actor.gleam
gleam_otp · Estado aislado por cliente · Sin condiciones de carrera
src/agentflow/agent_actor.gleam
Gleam
import gleam/erlang/process.{type Subject}
import gleam/otp/actor.{type Next}
import gleam/dict.{type Dict}
import agentflow/agent.{type Agent, type AgentStatus}

/// Mensajes que acepta el actor — protocolo tipado
pub type Message {
  ListAgents(
    owner_id: String,
    reply_to: Subject(List(Agent)),
  )
  CreateAgent(
    owner_id:   String,
    create_req: CreateRequest,
    reply_to:   Subject(Result(Agent, String)),
  )
  UpdateStatus(
    agent_id:   String,
    new_status: AgentStatus,
    reply_to:   Subject(Result(Agent, String)),
  )
  DeleteAgent(
    agent_id: String,
    reply_to: Subject(Result(Nil, String)),
  )
}

/// Estado interno del actor: Dict(owner_id, List(Agent))
pub type State {
  State(agents: Dict(String, List(Agent)), next_id: Int)
}

/// Loop del actor — pure function, sin efectos ocultos
fn handle_message(msg: Message, state: State) -> Next(Message, State) {
  case msg {
    ListAgents(owner_id, reply_to) -> {
      let agents = dict.get(state.agents, owner_id) |> result.unwrap([])
      process.send(reply_to, agents)
      actor.continue(state)
    }

    CreateAgent(owner_id, create_req, reply_to) -> {
      let id = "agent-" <> int.to_string(state.next_id)
      let new_agent = Agent(
        id:           id,
        name:         create_req.name,
        agent_type:   create_req.agent_type,
        status:       agent.Idle,
        owner_id:     owner_id,
        credits_used: 0,
        config:       create_req.config,
      )
      let owner_agents = dict.get(state.agents, owner_id) |> result.unwrap([])
      let new_state = State(
        agents: dict.insert(state.agents, owner_id, [new_agent, ..owner_agents]),
        next_id: state.next_id + 1,
      )
      process.send(reply_to, Ok(new_agent))
      actor.continue(new_state)
    }

    UpdateStatus(agent_id, new_status, reply_to) -> {
      case find_and_update(state, agent_id, new_status) {
        Ok(#(updated_agent, new_state)) -> {
          process.send(reply_to, Ok(updated_agent))
          actor.continue(new_state)
        }
        Error(err) -> {
          process.send(reply_to, Error(err))
          actor.continue(state)   // estado no cambia si hay error
        }
      }
    }

    DeleteAgent(agent_id, reply_to) -> {
      let #(found, new_agents) = remove_agent(state.agents, agent_id)
      case found {
        True -> {
          process.send(reply_to, Ok(Nil))
          actor.continue(State(..state, agents: new_agents))
        }
        False -> {
          process.send(reply_to, Error("Agent not found: " <> agent_id))
          actor.continue(state)
        }
      }
    }
  }
}

/// Arrancar el actor — devuelve un Subject para enviarle mensajes
pub fn start() -> Result(Subject(Message), actor.StartError) {
  actor.start(State(agents: dict.new(), next_id: 1), handle_message)
}
4
JSON Codec — src/agentflow/json_codec.gleam
gleam_json · Encode + Decode type-safe · Sin any
src/agentflow/json_codec.gleam
Gleam
import gleam/json.{type Json}
import gleam/dynamic/decode
import gleam/option.{type Option}
import agentflow/agent.{type Agent, type AgentType, type AgentStatus}

/// Serializar un agente a JSON — exhaustivo sobre AgentType y AgentStatus
pub fn encode_agent(a: Agent) -> Json {
  json.object([
    #("id",           json.string(a.id)),
    #("name",         json.string(a.name)),
    #("agent_type",   json.string(encode_agent_type(a.agent_type))),
    #("status",       encode_status(a.status)),
    #("owner_id",     json.string(a.owner_id)),
    #("credits_used", json.int(a.credits_used)),
    #("credits_per_run", json.int(agent.credits_per_run(a.agent_type))),
  ])
}

fn encode_agent_type(t: AgentType) -> String {
  case t {
    agent.ContentWriter  -> "content_writer"
    agent.SeoAnalyzer    -> "seo_analyzer"
    agent.AdCreative     -> "ad_creative"
    agent.EmailSequence  -> "email_sequence"
    agent.DataExtractor  -> "data_extractor"
    agent.Custom(name)   -> "custom:" <> name
  }
}

fn encode_status(s: AgentStatus) -> Json {
  case s {
    agent.Idle          -> json.object([#("state", json.string("idle"))])
    agent.Running       -> json.object([#("state", json.string("running"))])
    agent.Paused        -> json.object([#("state", json.string("paused"))])
    agent.Completed     -> json.object([#("state", json.string("completed"))])
    agent.Error(msg)    -> json.object([
      #("state",   json.string("error")),
      #("message", json.string(msg)),
    ])
  }
}

/// Decodificar request de creación — si falta campo, error tipado
pub fn decode_create_request(json: Dynamic) -> Result(CreateRequest, List(decode.DecodeError)) {
  decode.run(json, {
    use name       <- decode.field("name", decode.string)
    use agent_type <- decode.field("agent_type", decode_agent_type())
    use config     <- decode.optional_field("config", decode_config())
    decode.success(CreateRequest(name: name, agent_type: agent_type, config: config))
  })
}

fn decode_agent_type() -> decode.Decoder(AgentType) {
  decode.string
  |> decode.map(fn(s) {
    case s {
      "content_writer"  -> Ok(agent.ContentWriter)
      "seo_analyzer"    -> Ok(agent.SeoAnalyzer)
      "ad_creative"     -> Ok(agent.AdCreative)
      "email_sequence"  -> Ok(agent.EmailSequence)
      "data_extractor"  -> Ok(agent.DataExtractor)
      other             -> Ok(agent.Custom(other))
    }
  })
}
Por qué Gleam / BEAM para AgentFlow
🔒
Zero runtime crashes por tipos
Si compila, casi nunca falla en producción. No hay undefined, null sin manejar ni any. El compilador rechaza código con casos no cubiertos.
Millones de actores concurrentes
Un actor BEAM por cliente: cada uno tiene su estado aislado. No hay locks compartidos ni race conditions. Un crash no propaga al resto.
🔀
Pattern matching exhaustivo
Máquina de estados de agentes verificada por el compilador. Si añades un nuevo AgentStatus, el compilador señala cada sitio que lo usa.
🌐
Ecosistema Erlang/OTP completo
Acceso con @external a 30 años de librerías de Erlang: supervisores, ETS, GenServer, distributed nodes, hot code reloading.
📦
Deploy nativo en Fly.io
Fly.io tiene soporte nativo para BEAM (clustering automático). gleam build --target erlang produce un release listo para desplegar sin Docker complicado.
$
Setup del proyecto
Terminal
bash
# 1. Instalar Gleam (macOS)
brew install gleam

# 2. Crear proyecto
gleam new agentflow
cd agentflow

# 3. Añadir dependencias
gleam add wisp          # web framework type-safe
gleam add gleam_otp     # actores BEAM
gleam add gleam_json    # encode/decode
gleam add gleam_http    # tipos HTTP
gleam add mist          # servidor HTTP Erlang

# 4. Desarrollar
gleam run               # arranca el servidor
gleam test              # ejecuta tests (Gleeunit)
gleam build             # build de producción

# 5. Deploy en Fly.io
fly launch --name agentflow-api
fly deploy