[TERMINAL · SKILLS]
> mounting /skills...
> indexing skill manifests...
> linking agents: claude · codex · gemini · cursor
> ready.
[░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0%
Skills/Hasura
>

Hasura

Hasura GraphQL Engine v2 generates a real-time GraphQL API (queries, mutations, subscriptions) over PostgreSQL and other databases, with row-level permissions per role. Use when a user asks to set up or self-host Hasura with Docker, write permission rules, add Actions or Event Triggers for business logic, or manage migrations and metadata with the Hasura CLI. Covers Hasura v2 (graphql-engine), not Hasura DDN (v3).

#graphql#postgres#api#real-time#permissions
terminal-skillsv1.1.0
Works with:claude-codeopenai-codexgemini-clicursor
Source
Trust Score
87/ 100
1.86×
Impact

Validation

Quality
87/ 100
Does it follow best practices?
4 PASS · 2 WEAK
Security
Passed
No known issues
Content review + injection scan
Impact
1.86×
43% → 80% agent success
Avg across 3 eval scenarios
Scored 10/1/2026 · skill v1.1.0
agent@terminalskills — playground
full playground →

Prove Hasura on your task

simulated preview
$
$

real run in an isolated sandbox · files auto-deleted after 7 days · nothing touches your machine

$
✓ Installed hasura v1.1.0

Getting Started

  1. Install the skill using the command above
  2. Open your AI coding agent (Claude Code, Codex, Gemini CLI, or Cursor)
  3. Reference the skill in your prompt
  4. The AI will use the skill's capabilities automatically

Example Prompts

  • "Review the open pull requests and summarize what needs attention"
  • "Generate a changelog from the last 20 commits on the main branch"

Documentation

Overview

Hasura GraphQL Engine connects to a database and serves a GraphQL API for every tracked table: filtering, sorting, pagination, aggregations, mutations and live subscriptions, with access rules per role. Custom logic is attached through Actions (your HTTP handler behind a GraphQL field) and Event Triggers (webhooks on row changes).

This skill covers Hasura v2 — the hasura/graphql-engine server, the hasura CLI and YAML metadata. Hasura DDN (v3) is a separate product with its own ddn CLI, supergraph.yaml projects, HML metadata and data connectors; nothing below (env vars, metadata files, CLI commands) carries over to it. hasura.io/docs/latest now points at DDN, so use hasura.io/docs/2.0 for v2.

Instructions

Quick Start

yaml
# docker-compose.yml — local development
services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes: ["pgdata:/var/lib/postgresql/data"]
    healthcheck:   # Hasura exits if Postgres is not accepting connections yet
      { test: ["CMD-SHELL", "pg_isready -U postgres"], interval: 5s, retries: 10 }
  hasura:
    image: hasura/graphql-engine:v2.50.3.cli-migrations-v3   # server + bundled CLI
    user: "${HOST_UID}:${HOST_GID}"                           # files the CLI writes stay yours
    ports: ["8080:8080"]
    environment:
      # Registers this database as the source named "default"
      HASURA_GRAPHQL_DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD}@postgres:5432/postgres
      HASURA_GRAPHQL_ADMIN_SECRET: ${HASURA_GRAPHQL_ADMIN_SECRET}
      HASURA_GRAPHQL_ENABLE_CONSOLE: "true"
      HASURA_GRAPHQL_DEV_MODE: "true"          # detailed errors; turn off in production
      ACTION_BASE_URL: http://api:3000
      EVENT_WEBHOOK_SECRET: ${EVENT_WEBHOOK_SECRET}
    volumes: ["./hasura:/project"]
    depends_on:
      postgres:
        condition: service_healthy
volumes:
  pgdata:
bash
export HOST_UID=$(id -u) HOST_GID=$(id -g)     # plus the three secrets, or put all five in .env
mkdir -p hasura && docker compose up -d        # create ./hasura yourself, or Docker creates it root-owned
curl -s localhost:8080/healthz                 # OK
# Console at http://localhost:8080/console, GraphQL at http://localhost:8080/v1/graphql

HASURA_GRAPHQL_DATABASE_URL is kept for backwards compatibility. The official manifests instead set HASURA_GRAPHQL_METADATA_DATABASE_URL plus a variable of your choice such as PG_DATABASE_URL; with that layout no source exists until you add one in the Console or in metadata/databases/databases.yaml (database_url: { from_env: PG_DATABASE_URL }).

Auto-Generated API

graphql
# After tracking a "users" table, Hasura serves these operations:
query {
  users(
    where: { plan: { _eq: "pro" }, created_at: { _gte: "2026-01-01" } }
    order_by: { created_at: desc }, limit: 20, offset: 0
  ) {
    id email
    # Relationship defined on the users → orders foreign key
    orders(where: { status: { _eq: "paid" } }) { id amount }
    orders_aggregate { aggregate { count sum { amount } } }
  }
  users_aggregate(where: { plan: { _eq: "pro" } }) { aggregate { count } }
}

mutation {
  insert_users_one(object: { email: "maria@northwind.dev", plan: "free" }) { id }
  update_users(where: { id: { _eq: "60d686a1-c947-487b-a53e-3ed6c078f3ff" } }, _set: { plan: "pro" }) {
    affected_rows
    returning { id plan }
  }
}

# Live query over WebSocket: re-sent whenever the result changes
subscription {
  orders(where: { status: { _eq: "pending" } }) { id amount created_at }
}

Permissions (Row-Level Security)

yaml
# metadata/databases/default/tables/public_orders.yaml
table: { name: orders, schema: public }
object_relationships:
  - name: user
    using: { foreign_key_constraint_on: user_id }
# Select: users can only see their own orders
select_permissions:
  - role: user
    permission:
      columns: [id, amount, status, created_at]
      filter:
        user_id: { _eq: X-Hasura-User-Id }
      limit: 100
# Insert: users create orders for themselves
insert_permissions:
  - role: user
    permission:
      columns: [amount]
      set:
        user_id: X-Hasura-User-Id    # Taken from the session, not from the client
        status: pending
      check:
        amount: { _gt: 0 }
# Update: users can only cancel their own pending orders
update_permissions:
  - role: user
    permission:
      columns: [status]
      filter:
        user_id: { _eq: X-Hasura-User-Id }
        status: { _eq: pending }
      check:
        status: { _eq: cancelled }

List each table file in metadata/databases/default/tables/tables.yaml (- "!include public_orders.yaml"). The user relationship is inconsistent unless users is tracked too: add a public_users.yaml containing table: { name: users, schema: public } and list it as well. The admin role bypasses all permissions and needs no rules. A role can only select, filter on and return the columns its select permission lists; aggregate fields appear only with allow_aggregations: true.

Actions (Custom Business Logic)

An Action is declared in two files. The GraphQL signature and its types go in metadata/actions.graphql, the handler in metadata/actions.yaml:

graphql
type Mutation {
  processPayment(order_id: uuid!, payment_method: String!): PaymentResult
}
type PaymentResult {
  success: Boolean!
  payment_id: String!
}
yaml
# metadata/actions.yaml — handler and permissions
actions:
  - name: processPayment
    definition:
      kind: synchronous
      handler: "{{ACTION_BASE_URL}}/actions/process-payment"   # env var on the Hasura server
      forward_client_headers: true
    permissions:
      - role: user
custom_types:
  enums: []
  input_objects: []
  objects:
    - name: PaymentResult
  scalars: []
typescript
// api/actions/process-payment.ts — Hasura POSTs { action, input, session_variables, request_query }
export default async function handler(req: Request) {
  const { input, session_variables } = await req.json();
  const userId = session_variables["x-hasura-user-id"];   // keys are always lowercase
  const order = await db.query(
    "SELECT id, amount FROM orders WHERE id = $1 AND user_id = $2 AND status = 'pending'",
    [input.order_id, userId]
  );
  if (!order.rows[0]) {
    // Errors: 4xx status and a body with "message" (plus optional "extensions")
    return Response.json({ message: "Order not found", extensions: { code: "not-found" } }, { status: 400 });
  }
  const paymentId = await chargeCard(order.rows[0], input.payment_method);
  await db.query("UPDATE orders SET status = 'paid' WHERE id = $1", [input.order_id]);
  return Response.json({ success: true, payment_id: paymentId });   // 2xx + the output type's shape
}

Event Triggers

yaml
# metadata/databases/default/tables/public_orders.yaml (same file as the permissions)
event_triggers:
  - name: on_order_paid
    definition:
      enable_manual: false
      update:
        columns: [status]
    retry_conf: { num_retries: 3, interval_sec: 10, timeout_sec: 60 }
    webhook: http://api:3000/webhooks/order-paid
    headers:
      - name: x-webhook-secret
        value_from_env: EVENT_WEBHOOK_SECRET
typescript
// api/webhooks/order-paid.ts — body: { event: { op, data: { old, new } }, table, trigger, id }
export default async function handler(req: Request) {
  if (req.headers.get("x-webhook-secret") !== process.env.EVENT_WEBHOOK_SECRET) return new Response("Forbidden", { status: 403 });
  const { old: oldRow, new: newRow } = (await req.json()).event.data;
  // The trigger fires on every change of "status" — filter for the transition you want
  if (newRow.status === "paid" && oldRow?.status !== "paid") await sendEmail(newRow.user_id, `Order #${newRow.id} paid`);
  return Response.json({ success: true });   // any non-2xx response is retried per retry_conf
}

Migrations

bash
# One-time: scaffold config.yaml, metadata/, migrations/, seeds/
hasura init shop --endpoint http://localhost:8080
# Create and apply a schema change
hasura migrate create create_shop_tables --database-name default \
  --up-sql "CREATE TABLE users (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), email text UNIQUE NOT NULL, plan text NOT NULL DEFAULT 'free', created_at timestamptz NOT NULL DEFAULT now());
    CREATE TABLE orders (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), user_id uuid NOT NULL REFERENCES users(id), amount integer NOT NULL, status text NOT NULL DEFAULT 'pending', created_at timestamptz NOT NULL DEFAULT now());" \
  --down-sql "DROP TABLE orders; DROP TABLE users;"
hasura migrate apply --database-name default
# Push the metadata directory to the server and confirm it is consistent
hasura metadata apply && hasura metadata ic list
# Capture what was changed in the Console
hasura metadata export
hasura migrate create init --from-server --database-name default

The CLI reads the admin secret from the HASURA_GRAPHQL_ADMIN_SECRET environment variable. Passing --admin-secret to hasura init writes it into config.yaml.

Installation

bash
# CLI: bundled in the cli-migrations image as hasura-cli; the admin secret comes from the container env (run `init shop` with -w /project)
docker compose exec -e HOME=/tmp -w /project/shop hasura \
  hasura-cli migrate status --database-name default
# CLI via npm: community-maintained wrapper, not supported by Hasura; npm "latest" is 2.38.0
npm install --save-dev hasura-cli

Pin image versions (hasura/graphql-engine:v2.50.3 is the plain server); latest moves between minor releases. Standalone CLI binaries (cli-hasura-linux-amd64, cli-hasura-darwin-arm64, …) are attached to each GitHub release, without published checksums. The cli-migrations-v3 image also applies /hasura-migrations and /hasura-metadata on startup when those directories are mounted, which suits CI/CD deployments.

Examples

Example 1: Local Hasura where each user sees only their own orders

User request: "Set up Hasura locally for my users and orders tables. A logged-in user must only see their own orders."

The agent writes the compose file, the migration and public_orders.yaml above, runs docker compose up -d, hasura migrate apply --database-name default and hasura metadata apply, then checks the rule by sending the same query as two different users (the admin secret lets a request impersonate any role):

bash
curl -s localhost:8080/v1/graphql -H "content-type: application/json" \
  -H "x-hasura-admin-secret: $HASURA_GRAPHQL_ADMIN_SECRET" \
  -H "x-hasura-role: user" \
  -H "x-hasura-user-id: 60d686a1-c947-487b-a53e-3ed6c078f3ff" \
  -d '{"query":"{ orders { amount status } }"}'
# {"data":{"orders":[{"amount":4900,"status":"pending"}]}}

With another user's ID in x-hasura-user-id the result is {"data":{"orders":[]}}. With no headers at all: "x-hasura-admin-secret" required, but not found.

Example 2: Let users cancel an order but never mark it paid

User request: "Customers should be able to cancel their own pending orders from the app, and nothing else."

The agent adds the update_permissions rule above and applies the metadata. As role user, setting status to paid is rejected and cancelled goes through:

graphql
mutation {
  update_orders_by_pk(pk_columns: { id: "03b7104f-c239-416b-abf6-c7f91ea5779c" }, _set: { status: "paid" }) { id status }
}
json
{"errors":[{"message":"check constraint of an insert/update permission has failed","extensions":{"path":"$","code":"permission-error"}}]}
{"data":{"update_orders_by_pk":{"id":"03b7104f-c239-416b-abf6-c7f91ea5779c","status":"cancelled"}}}

Guidelines

  1. v2 or DDN — Check which product the project uses before touching it: config.yaml with version: 3 and a metadata/ directory of YAML is v2; supergraph.yaml and the ddn CLI mean DDN, and this skill does not apply
  2. Permissions on every table — Tables without permissions are invisible to non-admin roles; define permissions as part of your schema
  3. Use relationships — Define foreign key relationships; Hasura auto-generates nested queries (no N+1 problem)
  4. Event triggers for side effects — Use event triggers (not polling) for email, notifications, and external API calls; delivery is at-least-once and may arrive out of order, so make handlers idempotent
  5. Actions for business logic — Complex operations (payments, multi-step workflows) go in Actions, not in client code; verify a shared secret header in the handler so only Hasura can call it
  6. Migrations in CI/CD — Export metadata and migrations; apply them with the CLI or the cli-migrations-v3 image in your deployment pipeline
  7. Admin secret ≠ user auth — Always set HASURA_GRAPHQL_ADMIN_SECRET; without it the GraphQL endpoint and Console are open to anyone. Use JWT (HASURA_GRAPHQL_JWT_SECRET, claims under https://hasura.io/jwt/claims with x-hasura-default-role and x-hasura-allowed-roles) or webhook auth for users; never ship the admin secret to a browser
  8. Production settings — Set HASURA_GRAPHQL_ENABLE_CONSOLE and HASURA_GRAPHQL_DEV_MODE to "false"; dev mode leaks internal error details
  9. Subscriptions for real-time — Use GraphQL subscriptions instead of polling; Hasura handles WebSocket efficiently
  10. Use views for complex queries — Create PostgreSQL views for complex aggregations; track them as tables in Hasura