| name | atoms-api |
| description | Generate REST APIs from api-config.json for TypeScript or Rust backends |
| version | 1.0 |
| author | scoutos |
atoms-api Skill
Generate api-config.json files for backend-only atoms-kit applications.
Overview
This is a backend-only variant of atoms-kit that defines just the data layer:
- Atoms: Your data entities (e.g., contacts, deals, tickets)
- Fields: Field types and validation
- Relations: Relationships between atoms
No frontend (pages, navigation, theme) is defined — this is purely for generating a REST API layer.
When to Use
- Backend-only APIs with no frontend
- Headless CMS / API-first architecture
- Generating API routes that will be consumed by external frontends
- Microservices that need CRUD operations
Minimal Example
A simple task API:
{
"version": "1.0",
"app": {
"name": "Task API",
"description": "Simple task management API"
},
"atoms": {
"task": {
"label": "Task",
"icon": "RiCheckboxLine",
"fields": {
"title": { "type": "text", "required": true },
"description": { "type": "longtext" },
"status": {
"type": "enum",
"values": ["todo", "in_progress", "done"],
"default": "todo"
},
"priority": {
"type": "enum",
"values": ["low", "medium", "high"],
"default": "medium"
},
"assigned_to": { "type": "text", "description": "Clerk user ID" },
"due_at": { "type": "datetime" },
"team_id": { "type": "text" }
},
"relations": {
"comments": { "type": "has_many", "atom": "comment", "foreignField": "root_id" }
}
}
}
}
This generates:
Task type/struct with all fields plus id and org_id
- CRUD endpoints at
/api/atoms/task
- Auto-filtering by org_id, optional filtering by team_id
Schema Reference
Top-Level Structure
{
$schema?: string
version: "1.0"
app: AppMeta
atoms: Record<string, AtomConfig>
}
AppMeta
{
name: string
description?: string
}
AtomConfig
An atom is a data entity (like a database table).
{
label?: string
description?: string
icon?: IconName
fields: Record<string, FieldConfig>
relations?: Record<string, RelationConfig>
}
FieldConfig
Shorthand form (type only):
"name": "text"
Full object form:
"name": {
"type": "text",
"label": "Name",
"required": true,
"description": "User's full name",
"default": "Untitled"
}
Field types:
| Type | Use Case | Example |
|---|
text | Short text (names, titles) | { "type": "text" } |
longtext | Multi-line content | { "type": "longtext" } |
email | Email addresses | { "type": "email" } |
url | Web URLs | { "type": "url" } |
number | Numeric values | { "type": "number" } |
boolean | True/false | { "type": "boolean", "default": false } |
datetime | Date/time | { "type": "datetime" } |
json | Arbitrary JSON | { "type": "json" } |
enum | Fixed set of values | See below |
Enum fields:
"status": {
"type": "enum",
"label": "Status",
"values": ["open", "in_progress", "closed"],
"default": "open"
}
RelationConfig
{
type: "belongs_to" | "has_many" | "has_one"
atom: string
field?: string
foreignField?: string
}
Examples:
"relations": {
"company": {
"type": "belongs_to",
"atom": "company",
"field": "company_id"
},
"deals": {
"type": "has_many",
"atom": "deal",
"foreignField": "contact_id"
}
}
System Atoms
These are automatically available — do not define them in atoms:
| Atom | Purpose |
|---|
team | Multi-tenancy teams |
comment | Comments on any atom |
org_settings | Organization configuration |
atom_definition | Dynamic atom schemas |
Implicit Fields
Every atom automatically includes these fields — do NOT define them manually:
| Field | Type | Description |
|---|
id | string | Auto-generated unique identifier (UUID) |
org_id | string | Organization ID for multi-tenancy (auto-populated from auth) |
These are added by the API layer on all operations. Your fields definition should only include domain-specific fields.
Multi-Tenancy Model
Atoms-kit supports two levels of data scoping:
org_id — Organization Isolation (Hard Boundary)
- Source: Extracted from authentication token (Clerk JWT or API key default)
- Behavior: Auto-filtered on all GET queries, auto-injected on all writes
- Purpose: Complete data isolation between organizations/tenants
- User control: None — it's enforced by the API
User authenticates → Token contains org_id → API filters all data by org_id
team_id — Team Grouping Within Org (Soft Boundary)
- Source: User-defined field in your atom schema
- Behavior: Manual filtering via
?filter[team_id]=xxx
- Purpose: Sub-grouping data within an organization (e.g., sales team vs engineering team)
- User control: Optional — add
team_id field to atoms that need it
"fields": {
"name": { "type": "text" },
"team_id": { "type": "text", "description": "Team this belongs to" }
}
Key difference:
org_id = Hard isolation between companies/accounts (automatic)
team_id = Soft grouping within an org (opt-in, manual)
Authentication
All API requests require a Bearer token in the Authorization header:
Authorization: Bearer <token>
Backend (Server-to-Server)
Set the SCOUTOS_API_KEY environment variable. The API will:
- Accept requests without Clerk JWT
- Use
"default" as the org_id
- Allow full access to that org's data
SCOUTOS_API_KEY=sk_live_xxx
Frontend (User Sessions)
When using Clerk authentication:
- Clerk JWT is passed in Authorization header
userId and orgId are extracted from the token
- All queries are automatically scoped to that org
Auth Response Codes
| Status | Meaning |
|---|
401 | Missing or invalid token |
400 | Valid token but missing org membership |
Response Formats
All API responses use a consistent JSON envelope.
Single Record
{
"data": {
"id": "abc123",
"title": "Build API",
"status": "in_progress",
"priority": "high",
"assigned_to": "user_456",
"team_id": "team_789",
"org_id": "org_001"
}
}
List Records
{
"data": [
{ "id": "abc123", "title": "Build API", ... },
{ "id": "def456", "title": "Write tests", ... }
]
}
Note: The list response returns a flat array under data. Pagination metadata may be added via query params but is not included in the response body.
Create/Update Record
{
"data": {
"id": "xyz789",
"title": "New task",
"status": "todo",
"org_id": "org_001"
}
}
Delete Record
{
"success": true
}
Error Response
{
"error": "Unauthorized"
}
{
"error": "Unknown atom: invalid_atom_name"
}
Type Mappings
When implementing the API in TypeScript or Rust, use these type mappings:
TypeScript
| Field Type | TypeScript Type |
|---|
text | string |
longtext | string |
email | string |
url | string |
number | number |
boolean | boolean |
datetime | string (ISO 8601) |
json | unknown |
enum | string (union type recommended) |
Rust
| Field Type | Rust Type |
|---|
text | String |
longtext | String |
email | String |
url | String |
number | i64 |
boolean | bool |
datetime | chrono::DateTime<chrono::Utc> |
json | serde_json::Value |
enum | enum (derive Serialize, Deserialize) |
PostgreSQL
| Field Type | SQL Type |
|---|
text | VARCHAR(255) |
longtext | TEXT |
email | VARCHAR(255) |
url | VARCHAR(2048) |
number | BIGINT |
boolean | BOOLEAN |
datetime | TIMESTAMPTZ |
json | JSONB |
enum | VARCHAR(64) + CHECK constraint |
Recommended Indexes
CREATE INDEX idx_{atom}_org_id ON {atom}(org_id);
CREATE INDEX idx_{atom}_team_id ON {atom}(team_id); -- if team_id exists
CREATE INDEX idx_{atom}_created_at ON {atom}(created_at DESC);
-- Add indexes on frequently filtered enum fields (status, priority, etc.)
Implementation Notes
PUT Replaces Entire Document
The PUT /api/atoms/{atom}/{id} endpoint performs a full replacement, not a partial merge. Include all fields in your request body.
{ "title": "Updated title", "status": "done" }
{ "title": "Updated title", "status": "done", "assigned_to": "user_456", ... }
DELETE is Idempotent
DELETE returns { "success": true } even if the record didn't exist. This allows safe retries.
org_id is Auto-Injected
On POST and PUT:
- Do NOT include
org_id in request body
- It will be ignored if included
- The API injects the org_id from the auth token
Default Values
When creating a record:
- Explicit values in request body take precedence
- Field-level
default values from schema are applied
null/missing fields stay null
OpenAPI Example
Here's a minimal OpenAPI 3.0 specification for one atom:
openapi: 3.0.0
info:
title: Task API
version: 1.0.0
paths:
/api/atoms/task:
get:
summary: List tasks
parameters:
- name: filter[status]
in: query
schema:
type: string
enum: [todo, in_progress, done]
- name: filter[team_id]
in: query
schema:
type: string
- name: limit
in: query
schema:
type: integer
default: 50
responses:
'200':
description: List of tasks
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/Task'
post:
summary: Create a task
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TaskCreate'
responses:
'200':
description: Created task
content:
application/json:
schema:
type: object
properties:
data:
$ref: '#/components/schemas/Task'
/api/atoms/task/{id}:
get:
summary: Get a task
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: Task details
content:
application/json:
schema:
type: object
properties:
data:
$ref: '#/components/schemas/Task'
'404':
description: Not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
components:
schemas:
Task:
type: object
properties:
id:
type: string
title:
type: string
status:
type: string
enum: [todo, in_progress, done]
priority:
type: string
enum: [low, medium, high]
assigned_to:
type: string
nullable: true
team_id:
type: string
nullable: true
org_id:
type: string
TaskCreate:
type: object
required: [title]
properties:
title:
type: string
status:
type: string
enum: [todo, in_progress, done]
default: todo
priority:
type: string
enum: [low, medium, high]
default: medium
assigned_to:
type: string
team_id:
type: string
Error:
type: object
properties:
error:
type: string
securitySchemes:
BearerAuth:
type: http
scheme: bearer
security:
- BearerAuth: []
Available Icons
Choose from these Remix Icon names (optional, for tooling/display purposes):
RiDashboardLine, RiHome4Line, RiSettingsLine, RiSearchLine,
RiNotification3Line, RiInboxLine, RiAddLine, RiFilter2Line,
RiUserLine, RiGroupLine, RiTeamLine, RiUserHeartLine, RiUserSearchLine,
RiContactsBookLine, RiCustomerService2Line,
RiMoneyDollarCircleLine, RiShoppingCartLine, RiPriceTag3Line, RiCouponLine,
RiCheckboxLine, RiTodoLine, RiListCheck2, RiFolderLine, RiBriefcaseLine,
RiFileListLine, RiBookLine,
RiCalendarLine, RiVideoLine, RiTimeLine,
RiMegaphoneLine, RiMailLine, RiChat1Line,
RiBarChartLine, RiPieChartLine,
RiTicketLine,
RiCodeSSlashLine, RiLightbulbLine, RiAtomLine, RiAppsLine, RiDatabase2Line,
RiGlobalLine, RiHashtag, RiLink, RiLockLine, RiStarLine
Patterns & Conventions
Naming Conventions
| Element | Convention | Example |
|---|
| Atom names | singular, snake_case | contact, kb_article |
| Field names | snake_case | first_name, close_date |
Common Field Patterns
User references (Clerk user IDs):
"owner_id": { "type": "text", "label": "Owner", "description": "Clerk user ID" },
"assigned_to": { "type": "text", "label": "Assigned To", "description": "Clerk user ID" },
"created_by": { "type": "text", "label": "Created By", "description": "Clerk user ID" }
Foreign keys (belongs_to relations):
"company_id": { "type": "text", "label": "Company" },
"project_id": { "type": "text", "label": "Project" }
Standard timestamp fields:
"created_at": { "type": "datetime" },
"updated_at": { "type": "datetime" },
"due_at": { "type": "datetime", "label": "Due Date" },
"resolved_at": { "type": "datetime", "label": "Resolved At" }
Relation Patterns
Comments on any atom:
"comments": {
"type": "has_many",
"atom": "comment",
"foreignField": "root_id"
}
Belongs_to (child → parent):
"company": {
"type": "belongs_to",
"atom": "company",
"field": "company_id"
}
Has_many (parent → children):
"contacts": {
"type": "has_many",
"atom": "contact",
"foreignField": "company_id"
}
Agent Workflow
When asked to create a backend API from api-config.json:
-
Parse the config. Load the JSON and validate:
- All atoms have valid field types
- Relations reference atoms that exist
- Required fields are marked
-
Generate types. Create TypeScript interfaces or Rust structs:
- One type per atom
- Include implicit fields (
id, org_id)
- Handle enums as union types
-
Generate routes. For each atom, create CRUD endpoints:
GET /api/atoms/{atom} — List with filtering
POST /api/atoms/{atom} — Create
GET /api/atoms/{atom}/{id} — Get one
PUT /api/atoms/{atom}/{id} — Replace
DELETE /api/atoms/{atom}/{id} — Delete
-
Implement auth middleware. Extract org_id from:
- Bearer token (JWT with org_id claim)
- Or API key env var (default to "default" org)
-
Add multi-tenancy. Every query must filter by org_id, every write must inject it.
Common Validation Errors
| Error | Cause | Fix |
|---|
atom "X" is not defined | Relation references non-existent atom | Add the atom to atoms |
belongs_to relation must specify "field" | Missing foreign key field | Add "field": "atom_id" |
has_many relation must specify "foreignField" | Missing inverse field | Add "foreignField": "parent_id" |
duplicate atom name "X" | Two atoms with same name | Use unique atom names |
invalid field type "X" | Unknown field type | Use: text, longtext, number, boolean, datetime, json, enum, email, url |
Converting from app-config.json
If you have an existing app-config.json and want to extract just the backend:
- Remove
pages, navigation, and theme sections
- Remove any frontend-specific field options (like
card, list_columns, etc. from pages)
- Keep only
version, app, and atoms
The resulting api-config.json will define the same data model for API-only use.
CLI Usage
atoms validate api-config.json
atoms init ./my-api --config api-config.json