Mooncore

Mooncore

A lightweight, action-based API framework for Elixir.

What is Mooncore

Mooncore is a minimal Elixir framework for building APIs. It gives you everything you need — HTTP, WebSocket, authentication, middleware, and an MCP server for AI agents — with zero boilerplate and no heavyweight dependencies.

Mooncore runs on Bandit and Plug, so you get the performance and reliability of the BEAM with a surface area small enough to read in an afternoon.

Actions

At its core, Mooncore uses a single concept to model your entire API: actions. Every feature is a named function call — not an HTTP endpoint.

REST still works in Mooncore, but it stays a routing choice in your Plug.Router, not the center of the framework. Actions let you define the business operation once and expose it over HTTP, WebSocket, MCP, or direct Elixir calls:

REST                              Mooncore Action
────                              ────────
GET    /api/tasks                 "task.list"
POST   /api/tasks                 "task.create"
PUT    /api/tasks/:id             "task.update"
DELETE /api/tasks/:id             "task.delete"
POST   /api/tasks/:id/assign      "task.assign"
POST   /api/tasks/batch-archive   "task.batch_archive"
GET    /api/reports/weekly?...     "report.weekly"

No duplicate business logic, no route-specific handlers, no "which transport owns this logic" debates. Just action names and parameters.

REST and Routes

If you want a REST API, build it in your router and call into actions from each route. Mooncore provides the action pipeline and the HTTP adapter; you decide whether the public surface is action-shaped or REST-shaped.

get "/api/tasks" do
  Mooncore.Action.execute("task.list", %{auth: conn.assigns[:auth], params: %{}})
end

post "/api/tasks" do
  Mooncore.Action.execute("task.create", %{auth: conn.assigns[:auth], params: conn.body_params})
end

Transport Independence

Actions don't know how they were called. The same "task.create" works across every transport without modification:

               ┌─── HTTP POST /run
               │
               ├─── WebSocket message
               │
Action.run/2 ◄─┼─── Elixir function call
               │
               ├─── MCP tool call (AI agents)
               │
               ├─── Protobuf / gRPC adapter
               │
               ├─── Message queue consumer
               │
               └─── Cron scheduler

Write the logic once, plug in transports. Need a NATS consumer that triggers "order.process"? It's one adapter that calls Action.execute/2 — your handler doesn't change.

AI-Native Development

Actions are pure functions: a name, a parameter map, a result. This is the ideal interface for AI-assisted development — an agent can generate a handler, call it through the built-in MCP server, inspect the result, and iterate. No HTTP client setup, no URL construction, no status code interpretation. Just {"action": "task.create", "title": "Buy milk"}.

Because Mooncore includes an MCP server out of the box, AI agents connect directly to the running application. They discover available actions, execute them, read logs, and evaluate code — all through the same action interface your frontend uses. The functional style (map in, map out) means agents produce correct code faster with fewer tokens, since there's no framework boilerplate or object hierarchy to reason about.

Clean Separation

Because actions are transport-agnostic, your application logic has zero coupling to HTTP, WebSocket, or any delivery mechanism. This means:

Installation

Add to your mix.exs:

def deps do
  [
    {:mooncore, "~> 0.1.0"}
  ]
end

Quick Start

1. Define your app

defmodule MyApp.Roles do
  @roles ["admin", "manager", "user", "guest"]
  def list, do: @roles
end

defmodule MyApp.App do
  @behaviour Mooncore.App

  @impl true
  def list do
    %{
      "myapp" => %{
        key: "myapp",
        name: "My Application",
        roles: MyApp.Roles.list(),
        action_module: MyApp.Action
      }
    }
  end

  @impl true
  def info(app_name), do: Map.get(list(), app_name)
end

2. Define actions

Important:@actions must be defined beforeuse Mooncore.Action. The macro captures @actions at compile time.

defmodule MyApp.Action do
  @actions %{
    "echo"        => {MyApp.Action.Echo, :echo, [], %{}},
    "task.create" => {MyApp.Action.Task, :create, ~w(user), %{}},
    "task.list"   => {MyApp.Action.Task, :list, ~w(user), %{}},
  }

  use Mooncore.Action
end

defmodule MyApp.Action.Echo do
  def echo(req), do: %{echo: req[:params]}
end

defmodule MyApp.Action.Task do
  def create(req) do
    # req[:params] is the full request body:
    # %{"action" => "task.create", "title" => "Buy milk", ...}
    title = req[:params]["title"]
    # ... your persistence logic ...
    # Publish to WebSocket clients:
    Mooncore.Endpoint.Socket.publish(req[:auth]["dkey"], {"task-created", %{title: title}})
    {:ok, %{title: title}}
  end

  def list(req) do
    # ... your query logic ...
    {:ok, []}
  end
end

3. Define your router

You own the router. Mooncore provides plugs and helpers, you compose them however you want:

defmodule MyApp.Router do
  use Plug.Router

  plug Plug.Logger
  plug CORSPlug, origin: ["*"]
  plug Mooncore.Auth.Plug
  plug Plug.Parsers,
    parsers: [:urlencoded, :multipart, {:json, json_decoder: Jason}],
    length: 100_000_000
  plug :match
  plug :dispatch

  # Action endpoint — Mooncore handles dispatch + JSON response
  match "/run" do
    Mooncore.Endpoint.Http.handle(conn)
  end

  # Or handle it yourself for custom HTTP responses:
  match "/api" do
    result = Mooncore.Endpoint.Http.receive_action(conn)
    case result do
      %{error: "not found" <> _} ->
        conn |> put_resp_header("content-type", "application/json")
             |> send_resp(404, Jason.encode!(result))
      _ ->
        conn |> put_resp_header("content-type", "application/json")
             |> send_resp(200, Jason.encode!(Mooncore.Action.format_response(result)))
    end
  end

  # WebSocket
  get "/ws" do
    conn
    |> WebSockAdapter.upgrade(Mooncore.Endpoint.Socket.Handler, [conn: conn], timeout: 60_000)
    |> halt()
  end

  # Your custom routes
  get "/" do
    send_resp(conn, 200, "My App")
  end

  match _ do
    send_resp(conn, 404, "Not Found")
  end
end

4. Configure

# config/config.exs
config :mooncore,
  port: 4000,
  router: MyApp.Router,
  app_module: MyApp.App,
  jwt: [
    key: System.get_env("JWT_KEY"),
    issuer: "myapp"
  ],
  pools: [:default],
  before_action: [],
  after_action: []

5. Call actions from anywhere

# From HTTP — handled by router
# POST /run {"action": "task.create", "title": "Test"}

# From WebSocket — handled by socket handler
# {"action": "task.create", "title": "Test", "rayid": "abc"}

# From Elixir code — no transport needed
MyApp.Action.run("task.create", %{
  params: %{"action" => "task.create", "title" => "Test"},
  auth: %{"roles" => ["user"]}
})

# Through the middleware pipeline
Mooncore.Action.execute("task.create", %{
  params: %{"action" => "task.create", "title" => "Test"},
  auth: %{"roles" => ["user"]}
})

6. Run

mix run --no-halt

Mooncore.Application starts the Bandit HTTP server automatically — you don't need to add anything to your own supervision tree.

Middleware

Add before/after hooks to the action pipeline:

defmodule MyApp.Middleware.DBLink do
  @behaviour Mooncore.Middleware

  @impl true
  def call(req) do
    db = MyApp.DB.resolve(req[:auth]["dkey"])
    Map.put(req, :db, db)
  end
end

defmodule MyApp.Middleware.AuditLog do
  @behaviour Mooncore.Middleware

  @impl true
  def call(response) do
    # Log the response, strip sensitive data, etc.
    if is_map(response), do: Map.delete(response, "password"), else: response
  end
end

# config/config.exs
config :mooncore,
  before_action: [MyApp.Middleware.DBLink],
  after_action: [MyApp.Middleware.AuditLog]

WebSocket

Real-time pub/sub with channel support:

# Publish to connected clients
Mooncore.Endpoint.Socket.publish("group_key", {"event", data})
Mooncore.Endpoint.Socket.publish("group_key", {"event", data}, ["main:default", "chat:lobby"])

# Clients connect to /ws and can:
# - Authenticate: ["jwt", "token_string"]
# - Join channels: ["join", "channel_name"] (requires "channel_<name>" role)
# - Leave channels: ["leave", "channel_name"]
# - Send actions: {"action": "...", "params": {...}, "rayid": "..."}
# - Ping: "ping" → "pong"

MCP Server (AI Observability)

Query your running app's internals:

Mooncore.MCP.Server.list_actions()   # All registered actions
Mooncore.MCP.Server.list_clients()   # Connected WebSocket clients
Mooncore.MCP.Server.list_apps()      # Registered apps
Mooncore.MCP.Server.server_info()    # Server configuration

Action Definition Format

"action.name" => {Module, :function, required_roles, request_modifications}
Component Description
"action.name" Unique string identifier, dot-notation
Module Handler module
:function Function atom
required_roles[] = public, ~w(user) = requires "user" role
request_modifications Map deep-merged into request before calling handler

For AI Agents

If you are an AI coding agent, read guides/skills.md before generating Mooncore code. It contains scaffolding templates, critical rules and common patterns.

License

MIT