Table of Contents
- Introduction: The Backend Is Now a Conversation
- What Is This "Directus Skill" Anyway
- Prerequisites: What You Need Before You Start
- Installing the Skill in Hermes Agent
- Anatomy of a Single-Prompt Backend Specification
- Example 1: Blog Backend (Multilingual-Ready)
- Example 2: E-Commerce Backend in One Prompt
- Example 3: Simple CRM (Contacts + Companies + Interactions)
- How It Actually Works: The Skill's Secret Sauce
- Production-Proven Patterns We've Discovered
- 1. Schema and Meta Are Mandatory
- 2. Use get_expanded() for Relations
- 3. IDs Are Strings in GraphQL, Integers in REST
- 4. Junction Table Writes Go Through the Junction Collection
- 5. Translation Collections Need Two-Step Inserts
- 6. The POST Truncation Bug and the PATCH Fix
- 7. Verify Write Access Before Publishing
- Error Handling: What Goes Wrong and How to Fix It
- Advanced Patterns: Filters, Relations, and Enum Fields
- The Admin API: When You Need to Touch Schema Programmatically
- Putting It All Together: Your First Vibe-Code Session
- What This Enables: Rapid Prototyping at Scale
- Limitations and Gotchas
- Conclusion: The Backend Is Now a Conversation
- Next Steps
Introduction: The Backend Is Now a Conversation
!Vibe-coding workflow diagram: natural language prompt to Directus schema to production API
Here's a frustrating pattern most developers know well:
- You have a brilliant app idea
- You start scaffolding the database -
users,posts,orders - You build the API layer - REST endpoints, GraphQL resolvers
- You wire up auth, permissions, validation
- Three days later you finally get to the actual feature you wanted to build
That friction is real. And in 2026, it's dead.
What if you could skip steps 2 through 4 entirely? What if you could describe your entire backend in plain English, hit enter, and have a production-ready schema, API, and admin panel ready to go?
That's what vibe-coding a backend means. And with Directus - the open-source headless CMS that turns your database into an instant API - paired with the nikola66 Directus skill (a universal GraphQL client for AI agents), it's now possible to scaffold an entire data layer in one shot.
This isn't a toy demo. This is a real workflow we're using at Ainex to spin up project backends in minutes, not days. In this guide, you'll learn:
- What the Directus skill actually does under the hood
- How to install it and connect to your Directus instance
- The anatomy of a single-prompt backend specification
- Step-by-step examples: building a blog, an e-commerce store, and a CRM
- Production patterns, error handling, and gotchas we've already hit in the wild
- How to verify your newly-scaffolded backend is live and queryable
Let's get into it.
What Is This "Directus Skill" Anyway?
The skill at nikola66/directus-skill is a Python client that speaks Directus's GraphQL and REST APIs directly. It's designed for AI agents like Hermes, but you can also use it as a standalone library.
Key properties that make it vibe-coding possible:
- Collection-agnostic: It doesn't care if you're working with
Blog_PostsorEcom_Products. The same methods work on any collection. - Full CRUD surface:
get,insert,update,delete,count, and evencreate_collectionandcreate_fieldfor schema operations (admin only). - Smart relation expansion: Use dot-notation like
author.nameororder_items.product.titleand the skill builds the correct nested GraphQL selection sets. - Filter operators: All Directus filters supported -
_eq,_contains,_in,_gt,_null, etc. - Error handling: Raises
DirectusErrorwith structured details so you know exactly why something failed.
When you load this skill into an AI agent, you give that agent the ability to bootstrap an entire database schema through conversation.
Prerequisites: What You Need Before You Start
Before you can vibe-code a backend, you need three things:
- A Directus instance - either self-hosted or Directus Cloud. We run at
https://hub.aratech.ae. - An API token with appropriate permissions. For schema creation, you'll need an admin token. Get it from Directus → Settings → API Tokens → Create (Admin scope, all permissions).
- Python 3.9+ with the skill installed:
# Option A: Install from PyPI (when published)
pip install directus-skill
## Option B: Clone and use directly (current)
git clone https://github.com/nikola66/directus-skill.git
## Copy the package into your project or add to sys.pathAs of April 2026, the skill is actively maintained and production-validated against a Directus 10+ instance with full CRUD, filter operators, and collection management working reliably.
Installing the Skill in Hermes Agent
If you're running Hermes Agent (our multi-agent orchestration platform), adding the skill is a single command:
hermes skills install directusOr to add the upstream GitHub version directly:
npx skills add nikola66/directus-skillOnce installed, the directus skill is loaded automatically when you ask about Directus operations. You don't need to manually import anything - the agent handles that.
Set your environment variables (we keep these in ~/.bashrc or the gateway session):
export DIRECTUS_URL="https://hub.aratech.ae"
export DIRECTUS_API_TOKEN="user_token_here" # CRUD operations
export DIRECTUS_ADMIN_TOKEN="admin_token_here" # Schema managementNote: In the Hermes gateway, these are injected automatically from the gateway config. If you're running standalone scripts, set them yourself.
Anatomy of a Single-Prompt Backend Specification
The magic happens when you describe your entire data model in one natural-language prompt. Here's the pattern:
Build me a [TYPE] backend with Directus. Create these collections:
1. [Collection Name] - [purpose]
- field_name (type), field2 (type), ...
- relationships: [relation details]
2. [Collection Name] - ...
...
After creating all collections:
- Verify by calling get_collections() and printing the list
- Seed [N] sample records for each collection
- Output the complete Python scriptThe agent interprets this, translates it into Directus admin API calls (POST /collections with full field definitions), executes it, and returns a working schema.
The skill does the heavy lifting:
- Builds the correct payload structure for Directus's quirky
schemaandmetarequirements - Calls
create_collection()per collection with the full field list - Optionally seeds sample data via CRUD operations
- Verifies success with
get_collections()
All in one execution, no manual intervention.
Example 1: Blog Backend (Multilingual-Ready)
Let's start with a classic - a blog that supports 5 languages. Here's the complete prompt you'd give the agent:
Build me a multilingual blog backend with Directus. Create these collections:
1. Blog_Authors - author profiles
- name (string), slug (string, unique), bio (text), avatar (file), social_twitter (string, nullable)
- status: draft/published
- Fields: id, created_at, updated_at
2. Blog_Categories - hierarchical taxonomy
- name (string), slug (string, unique), description (text), parent_category (many-to-one → Blog_Categories, nullable)
- icon (string, icon name), color (string, hex)
- Fields: id, sort_order
3. Blog_Tags - flat tags
- name (string), slug (string, unique), color (string, hex)
- Fields: id
4. Blog_Posts - parent records (metadata only, per our schema design)
- status (draft/published), published_at (datetime, nullable)
- author (many-to-one → Blog_Authors), category (many-to-one → Blog_Categories)
- featured_image (file), reading_time_minutes (integer)
- is_featured (boolean), is_pillar (boolean)
- Fields: id, created_at, updated_at
5. Blog_Posts_Translations - per-language content (child records)
- blog_posts_id (FK integer → Blog_Posts), languages_code (string: en/ar/de/es/fr)
- title (string), slug (string), content (markdown text)
- excerpt (text, max 200 chars), key_takeaways (json array, 3-5 items)
- meta_title (string, ≤60), meta_description (string, ≤160)
- featured_image_alt (string)
- cta_headline (string), cta_button_text (string), cta_button_url (string)
- schema_markup (json - Article schema.org)
- Fields: id
6. Blog_Posts_Tags - junction for many-to-many post ↔ tag
- blog_posts_id (FK → Blog_Posts), blog_tags_id (FK → Blog_Tags)
- Fields: id
Important:
- Use create_collection() for all 6 collections
- Every field must include schema: {} and meta: {} in the payload
- Define ALL fields up front (no incremental field adds)
- After creating collections, seed 2 sample blog posts minimum:
* Post 1: "Introduction to AI Security" - author=1, category=33 (AI & LLMs), published
* Post 2: "Understanding SOC 2 Compliance" - author=1, category=37 (Compliance), draft
* For each post, create EN translation only (skip AR/DE/ES/FR for now)
- Print verification: list all collections and their record countsWhat This Does
When you run this prompt through an agent with the Directus skill loaded:
- Creates 6 collections with full field definitions in correct order (parents first, then junctions, then translations)
- Adds proper relationships - Many-to-One from Posts → Authors/Category, Many-to-Many via junction table
- Respects your actual schema design - Translation records are separate child collection linked by
blog_posts_id - Seeds real data - Two sample posts with required fields filled
- Verifies - Prints what was created
The Generated Code (What's Running Under the Hood)
The agent produces something like this Python script:
from directus.client import DirectusClient
import os
client = DirectusClient(
url=os.getenv('DIRECTUS_URL'),
token=os.getenv('DIRECTUS_ADMIN_TOKEN') # admin token for schema ops
)
## 1. Blog_Authors
client.create_collection('Blog_Authors', fields=[
{'field': 'id', 'type': 'integer', 'schema': {'is_primary_key': True, 'has_auto_increment': True}, 'meta': {'hidden': True, 'interface': 'numeric', 'readonly': True}},
{'field': 'name', 'type': 'string', 'schema': {}, 'meta': {'interface': 'input'}},
{'field': 'slug', 'type': 'string', 'schema': {'is_unique': True}, 'meta': {'interface': 'input'}},
{'field': 'bio', 'type': 'text', 'schema': {}, 'meta': {'interface': 'textarea'}},
{'field': 'avatar', 'type': 'file', 'schema': {}, 'meta': {'interface': 'file', 'options': {'accepted_files': 'image/*'}}},
{'field': 'social_twitter', 'type': 'string', 'schema': {}, 'meta': {'interface': 'input'}},
{'field': 'status', 'type': 'string', 'schema': {}, 'meta': {'interface': 'select', 'options': {'choices': ['draft', 'published']}}},
{'field': 'created_at', 'type': 'timestamp', 'schema': {}, 'meta': {'interface': 'datetime', 'readonly': True}},
{'field': 'updated_at', 'type': 'timestamp', 'schema': {}, 'meta': {'interface': 'datetime', 'readonly': True}}
])
## 2. Blog_Categories
client.create_collection('Blog_Categories', fields=[
{'field': 'id', 'type': 'integer', 'schema': {'is_primary_key': True, 'has_auto_increment': True}, 'meta': {'hidden': True, 'interface': 'numeric', 'readonly': True}},
{'field': 'name', 'type': 'string', 'schema': {}, 'meta': {'interface': 'input'}},
{'field': 'slug', 'type': 'string', 'schema': {'is_unique': True}, 'meta': {'interface': 'input'}},
{'field': 'description', 'type': 'text', 'schema': {}, 'meta': {'interface': 'textarea'}},
{'field': 'parent_category', 'type': 'integer', 'schema': {}, 'meta': {'interface': 'relation', 'options': {'related_collection': 'Blog_Categories'}}},
{'field': 'icon', 'type': 'string', 'schema': {}, 'meta': {'interface': 'input'}},
{'field': 'color', 'type': 'string', 'schema': {}, 'meta': {'interface': 'color'}},
{'field': 'sort_order', 'type': 'integer', 'schema': {}, 'meta': {'interface': 'numeric'}},
{'field': 'created_at', 'type': 'timestamp', 'schema': {}, 'meta': {'interface': 'datetime', 'readonly': True}},
{'field': 'updated_at', 'type': 'timestamp', 'schema': {}, 'meta': {'interface': 'datetime', 'readonly': True}}
])
## 3. Blog_Tags
client.create_collection('Blog_Tags', fields=[
{'field': 'id', 'type': 'integer', 'schema': {'is_primary_key': True, 'has_auto_increment': True}, 'meta': {'hidden': True, 'interface': 'numeric', 'readonly': True}},
{'field': 'name', 'type': 'string', 'schema': {}, 'meta': {'interface': 'input'}},
{'field': 'slug', 'type': 'string', 'schema': {'is_unique': True}, 'meta': {'interface': 'input'}},
{'field': 'color', 'type': 'string', 'schema': {}, 'meta': {'interface': 'color'}},
{'field': 'created_at', 'type': 'timestamp', 'schema': {}, 'meta': {'interface': 'datetime', 'readonly': True}}
])
## 4. Blog_Posts - parent metadata record
client.create_collection('Blog_Posts', fields=[
{'field': 'id', 'type': 'integer', 'schema': {'is_primary_key': True, 'has_auto_increment': True}, 'meta': {'hidden': True, 'interface': 'numeric', 'readonly': True}},
{'field': 'status', 'type': 'string', 'schema': {}, 'meta': {'interface': 'select', 'options': {'choices': ['draft', 'published']}}},
{'field': 'published_at', 'type': 'timestamp', 'schema': {}, 'meta': {'interface': 'datetime', 'readonly': False}},
{'field': 'author', 'type': 'integer', 'schema': {}, 'meta': {'interface': 'relation', 'options': {'related_collection': 'Blog_Authors'}}},
{'field': 'category', 'type': 'integer', 'schema': {}, 'meta': {'interface': 'relation', 'options': {'related_collection': 'Blog_Categories'}}},
{'field': 'featured_image', 'type': 'file', 'schema': {}, 'meta': {'interface': 'file', 'options': {'accepted_files': 'image/*'}}},
{'field': 'reading_time_minutes', 'type': 'integer', 'schema': {}, 'meta': {'interface': 'numeric'}},
{'field': 'is_featured', 'type': 'boolean', 'schema': {}, 'meta': {'interface': 'boolean'}},
{'field': 'is_pillar', 'type': 'boolean', 'schema': {}, 'meta': {'interface': 'boolean'}},
{'field': 'created_at', 'type': 'timestamp', 'schema': {}, 'meta': {'interface': 'datetime', 'readonly': True}},
{'field': 'updated_at', 'type': 'timestamp', 'schema': {}, 'meta': {'interface': 'datetime', 'readonly': True}}
])
## 5. Blog_Posts_Translations - content per language
client.create_collection('Blog_Posts_Translations', fields=[
{'field': 'id', 'type': 'integer', 'schema': {'is_primary_key': True, 'has_auto_increment': True}, 'meta': {'hidden': True, 'interface': 'numeric', 'readonly': True}},
{'field': 'blog_posts_id', 'type': 'integer', 'schema': {}, 'meta': {'interface': 'relation', 'options': {'related_collection': 'Blog_Posts'}}},
{'field': 'languages_code', 'type': 'string', 'schema': {}, 'meta': {'interface': 'select', 'options': {'choices': ['en', 'ar', 'de', 'es', 'fr']}}},
{'field': 'title', 'type': 'string', 'schema': {'is_required': True}, 'meta': {'interface': 'input'}},
{'field': 'slug', 'type': 'string', 'schema': {'is_required': True, 'is_unique': True}, 'meta': {'interface': 'input'}},
{'field': 'content', 'type': 'text', 'schema': {}, 'meta': {'interface': 'markdown'}},
{'field': 'excerpt', 'type': 'text', 'schema': {}, 'meta': {'interface': 'textarea'}},
{'field': 'key_takeaways', 'type': 'json', 'schema': {}, 'meta': {'interface': 'list'}},
{'field': 'meta_title', 'type': 'string', 'schema': {}, 'meta': {'interface': 'input'}},
{'field': 'meta_description', 'type': 'text', 'schema': {}, 'meta': {'interface': 'textarea'}},
{'field': 'featured_image_alt', 'type': 'text', 'schema': {}, 'meta': {'interface': 'textarea'}},
{'field': 'cta_headline', 'type': 'string', 'schema': {}, 'meta': {'interface': 'input'}},
{'field': 'cta_button_text', 'type': 'string', 'schema': {}, 'meta': {'interface': 'input'}},
{'field': 'cta_button_url', 'type': 'string', 'schema': {}, 'meta': {'interface': 'input'}},
{'field': 'schema_markup', 'type': 'json', 'schema': {}, 'meta': {'interface': 'json'}},
{'field': 'created_at', 'type': 'timestamp', 'schema': {}, 'meta': {'interface': 'datetime', 'readonly': True}},
{'field': 'updated_at', 'type': 'timestamp', 'schema': {}, 'meta': {'interface': 'datetime', 'readonly': True}}
])
## 6. Blog_Posts_Tags - junction table
client.create_collection('Blog_Posts_Tags', fields=[
{'field': 'id', 'type': 'integer', 'schema': {'is_primary_key': True, 'has_auto_increment': True}, 'meta': {'hidden': True, 'interface': 'numeric', 'readonly': True}},
{'field': 'blog_posts_id', 'type': 'integer', 'schema': {}, 'meta': {'interface': 'relation', 'options': {'related_collection': 'Blog_Posts'}}},
{'field': 'blog_tags_id', 'type': 'integer', 'schema': {}, 'meta': {'interface': 'relation', 'options': {'related_collection': 'Blog_Tags'}}},
{'field': 'created_at', 'type': 'timestamp', 'schema': {}, 'meta': {'interface': 'datetime', 'readonly': True}}
])
## Seed sample data
## 1. Create author (if not exists)
author = client.insert({'name': 'Alex Chen', 'slug': 'alex-chen', 'bio': 'Security engineer at Ainex.', 'status': 'published'}, collection='Blog_Authors')
author_id = author['id']
## 2. Create category
category = client.insert({'name': 'AI & LLMs', 'slug': 'ai-llms', 'description': 'Articles about AI security and LLMs.', 'sort_order': 1}, collection='Blog_Categories')
category_id = category['id']
## 3. Create parent blog post record
post = client.insert({
'status': 'published',
'author': author_id,
'category': category_id,
'reading_time_minutes': 8,
'is_featured': False,
'published_at': '2026-05-03T10:00:00.000Z'
}, collection='Blog_Posts')
post_id = post['id']
## 4. Create English translation
client.insert({
'blog_posts_id': post_id,
'languages_code': 'en',
'title': 'Introduction to AI Security',
'slug': 'intro-to-ai-security',
'content': '# AI Security Fundamentals\n\nThis is a sample article about securing AI systems...\n\n## Key Risks\n\n- Prompt injection\n- Data leakage\n- Model poisoning\n\n## Best Practices\n\nAlways validate inputs, sandbox model execution, and monitor for anomalies.',
'excerpt': 'A beginner-friendly guide to understanding and mitigating AI system risks.',
'key_takeaways': ['AI systems are vulnerable to prompt injection attacks', 'Always validate and sanitize model inputs', 'Monitor for anomalous model behavior'],
'meta_title': 'Introduction to AI Security - Ainex Guide',
'meta_description': 'Learn the fundamentals of AI security, common threats, and best practices for protecting your LLM applications.',
'featured_image_alt': 'Abstract visualization of neural network security',
'cta_headline': 'Ready to secure your AI?',
'cta_button_text': 'Get Started',
'cta_button_url': '/get-started',
'schema_markup': {
'@context': 'https://schema.org',
'@type': 'Article',
'headline': 'Introduction to AI Security',
'author': {'@type': 'Person', 'name': 'Alex Chen'},
'datePublished': '2026-05-03T10:00:00.000Z',
'description': 'A beginner-friendly guide to understanding and mitigating AI system risks.'
}
}, collection='Blog_Posts_Translations')
print(f"✅ Blog backend created! Post ID: {post_id}")That script is generated and executed by the agent in a single turn. You get a full, queryable schema and seeded data immediately.
Example 2: E-Commerce Backend in One Prompt
Let's go bigger. Here's how you'd scaffold a complete e-commerce data layer:
Build a production e-commerce backend with Directus. Create these collections:
1. **Products** - catalog items
- title, slug (unique), description (text), status (draft/available/out_of_stock)
- price_cents (integer), compare_price_cents (integer nullable), cost_cents (integer)
- sku (unique), barcode (nullable), weight_grams (integer)
- inventory_quantity, low_stock_threshold (default 10)
- featured_image (file), images (M2M via junction)
- category (M2O → Categories), tags (M2M → Tags via junction)
- published_at
2. **Categories** - nested taxonomy
- name, slug (unique), description, parent_category (nullable M2O self)
- image (file), sort_order
3. **Tags** - product tags
- name, slug (unique), color (hex)
4. **Customers** - accounts
- email (unique), first_name, last_name, phone
- default_shipping_address (json), default_billing_address (json)
- total_spent_cents, order_count, last_order_at (nullable)
- status (active/blocked), notes
5. **Orders** - transactions
- order_number (unique), status (pending/processing/shipped/delivered/cancelled)
- customer (M2O → Customers)
- subtotal_cents, tax_cents, shipping_cents, total_cents, discount_cents
- shipping_address, billing_address (json)
- payment_status (paid/refunded/failed), payment_method (stripe/paypal/manual)
- payment_intent_id (nullable), tracking_number (nullable)
- notes, cancelled_at (nullable)
6. **Order_Items** - line items (junction table Orders ↔ Products)
- order (M2O → Orders), product (M2O → Products)
- quantity, unit_price_cents, total_price_cents
- variant_name (string, nullable)
7. **Inventory_Adjustments** - audit log
- product (M2O → Products), reason (restock/sale/return/audit/adjustment)
- delta_quantity, previous_quantity, new_quantity
- reference_id (nullable - links to order/adjustment), notes
8. **Collections** - curated groupings
- name, slug (unique), handle (API-friendly string)
- type (manual/automatic), rules (json for automatic rules)
- sort_order, published (boolean)
9. **Discounts** - promo codes
- code (unique, nullable for automatic), type (percentage/fixed)
- value (int - percent or cents), max_uses, used_count
- valid_from, valid_until (nullable)
- applies_to (all/categories/products), applicable_product_ids (json array)
- min_order_amount_cents (nullable), exclude_sale_items (boolean)
Constraints:
- Use create_collection() for all 9 collections + explicit junction tables (Products_Images if needed)
- Every field: include schema: {} and meta: {} up front
- Use proper Directus field types: integer, string, text, boolean, datetime, json, file, pivot
- Add unique indexes on slug, sku, order_number
- Seed: create 3 sample products with sample images (use file IDs if available), link to category
- Verify: call get_collections() and print record counts
- Output the full Python scriptRunning this through the agent produces a ~300-line script that builds your entire e-commerce data model in one go. From zero to "here's your admin panel" before your coffee gets cold.
Example 3: Simple CRM (Contacts + Companies + Interactions)
Need something lightweight? Here's a CRM:
Build a CRM backend with Directus:
1. Companies
- name, domain (unique), industry, size (1-10/11-50/51-200/200+)
- website, phone, address (json), annual_revenue_cents (nullable)
- owner (M2O → Users), status (lead/customer/prospect/churned)
- last_contacted_at, notes (text)
2. Contacts
- first_name, last_name, email (unique), phone, job_title
- company (M2O → Companies), owner (M2O → Users)
- status (active/inactive/lead), preferred_contact (email/phone/linkedin)
- linkedin_url, notes
3. Interactions
- contact (M2O → Contacts), type (call/email/meeting/note)
- direction (inbound/outbound), duration_minutes (integer)
- summary (text), next_steps (text), due_date (nullable)
- related_deal (M2O → Deals nullable)
4. Deals
- title, value_cents, stage (proposal/negotiation/closed_won/closed_lost)
- probability (0-100), close_date (expected)
- contact (M2O → Contacts), owner (M2O → Users)
- notes
Constraints:
- Use create_collection() for all 4
- All standard fields: id, created_at, updated_at automatically added
- Relationships set up correctly (FKs are integers)
- Seed: 2 companies, 4 contacts, 3 interactions, 1 dealAgain - one prompt, one execution.
How It Actually Works: The Skill's Secret Sauce
Let's demystify what happens when you ask the agent to "build a backend":
Step 1 - Agent Parses Your Prompt
The agent uses its reasoning to extract:
- Collection names and their purpose
- Field names, types, and constraints
- Relationship directions (many-to-one, many-to-many)
- Seed data requirements
- Verification steps
It doesn't need to see a schema definition language - it just reads your plain English and builds the structure.
Step 2 - Skill Generates Correct Payloads
This is where the skill shines. Directus's POST /collections endpoint is famously picky:
Without schema: {} and meta: {} per field? → 403 Forbidden (even with a valid admin token).
Wrong field type name? → The collection silently doesn't appear.
Missing relationship meta options? → Relations don't work.
The skill knows the Directus API contract and generates production-correct payloads automatically. Here's what a correct field definition looks like:
{
'field': 'price_cents',
'type': 'integer',
'schema': {'is_required': True}, # ← always include this
'meta': {'interface': 'numeric'} # ← always include this
}Step 3 - Admin API Calls in Order
Collections are created in dependency order (parents before children, no circular deps). For each:
POST /collectionscreates the collection with full field list- Skill verifies with
GET /collections/{name}that it exists - If it already exists, it drops and recreates (idempotent mode)
For junction tables (M2M), the pivot type or explicit FK fields with meta.interface: 'relation' are used.
Step 4 - Seed Data + Verification
After all collections exist, the agent:
- Inserts sample records using standard CRUD (
insert()) - Queries back with
get()orget_expanded()to confirm - Prints a summary to your terminal
Production-Proven Patterns We've Discovered
We've run this workflow against https://hub.aratech.ae with our full blog schema. Here are the lessons:
1. Schema and Meta Are Mandatory
This can't be stressed enough. Directus 10+ returns 403 when either is missing, but the error message is misleading ("You don't have permission") when the real issue is your payload structure.
Always check:
- Collection payload has
schema: {}andmeta: {}at the top level - Every single field has both
schemaandmeta - Field
typevalues are valid Directus types (integer, notint;string, notvarchar)
2. Use get_expanded() for Relations
Directus GraphQL requires nested selection sets for relation fields. If you try:
{ Blog_Posts_Translations { id blog_posts_id } }You get a 400 error: Field "blog_posts_id" of type "Blog_Posts" must have a selection of subfields.
The skill's get_expanded() method with dot-notation handles this automatically:
client.get_expanded(
fields=['id', 'title', 'blog_posts_id.id', 'blog_posts_id.status'],
collection='Blog_Posts_Translations',
limit=50
)
## Returns dicts: blog_posts_id = {'id': '5', 'status': 'published'}3. IDs Are Strings in GraphQL, Integers in REST
GraphQL returns all id fields as strings. But foreign key fields (like blog_posts_id) are integers when used as filters or insert values.
When you create a post and get back post_id = "42", you must cast:
client.insert({
'blog_posts_id': int(post_id), # NOT post_id
'languages_code': 'en',
...
}, collection='Blog_Posts_Translations')Otherwise you get a 400 GraphQL validation error.
4. Junction Table Writes Go Through the Junction Collection
Directus exposes M2M relationships as read-only virtual fields on the parent. You cannot PATCH the tags field on Blog_Posts directly - you get 403 Forbidden.
Instead, write to the junction table:
## WRONG: Direct PATCH on Blog_Posts fails
client.update(post_id, {'tags': [1, 2, 3]}, collection='Blog_Posts') # ❌ 403
## CORRECT: Insert into junction table
for tag_id in [1, 2, 3]:
client.insert({'blog_posts_id': post_id, 'blog_tags_id': tag_id}, collection='Blog_Posts_Tags')5. Translation Collections Need Two-Step Inserts
Per our schema design: first create the parent Blog_Posts record, then create the child Blog_Posts_Translations record with the FK:
## Step 1: parent (metadata only)
post = client.insert({
'status': 'published',
'author': author_id,
'category': category_id,
'reading_time_minutes': 8
}, collection='Blog_Posts')
post_id = post['id']
## Step 2: translation (actual content)
client.insert({
'blog_posts_id': post_id,
'languages_code': 'en',
'title': 'My Post',
'slug': 'my-post',
'content': '# Hello world...',
...
}, collection='Blog_Posts_Translations')6. The POST Truncation Bug and the PATCH Fix
We discovered that large content field POSTs (>~1500 chars) get silently truncated by a gateway/proxy layer. The REST response returns success, but only the first ~1.5KB of your markdown is stored.
Mitigation: Create the translation record with initial data, then immediately PATCH the content field:
## Step A: Create (truncation risk)
trans = client.insert({ ... initial_fields ..., 'content': 'short placeholder' }, collection='Blog_Posts_Translations')
trans_id = trans['id']
## Step B: PATCH full content (bypasses truncation)
client.update(trans_id, {'content': full_markdown_body}, collection='Blog_Posts_Translations')7. Verify Write Access Before Publishing
Before attempting a critical write (publish, bulk update), do a lightweight test:
## Send a minimal test record
test = {'status': 'draft', 'author': 1, 'category': 33}
test_record = client.insert(test, collection='Blog_Posts')
test_id = test_record['id']
## If successful, delete it
client.delete(test_id, hard=True, collection='Blog_Posts')
print('✅ Write access confirmed')If this fails with 403/404, your token doesn't have write permissions - no point attempting the full operation.
Error Handling: What Goes Wrong and How to Fix It
Here's the error table from production:
The skill already handles most of these internally now (v1.4.1+), but it's good to know the patterns.
Advanced Patterns: Filters, Relations, and Enum Fields
Filtering with Operators
The skill supports all Directus filter operators:
## Equal
results = client.get(
fields=['id', 'title', 'status'],
filters={'status': {'_eq': 'published'}},
collection='Blog_Posts',
limit=10
)
## Not equal
drafts = client.get(filters={'status': {'_neq': 'published'}}, collection='Blog_Posts')
## Contains (case-insensitive)
search = client.get(
filters={'title': {'_icontains': 'security'}},
collection='Blog_Posts'
)
## In list
results = client.get(
filters={'category': {'_in': [33, 37, 42]}},
collection='Blog_Posts'
)
## Greater than
expensive = client.get(
filters={'price_cents': {'_gt': 10000}},
collection='Products'
)
## Null check
uncontacted = client.get(
filters={'last_contacted_at': {'_null': True}},
collection='Contacts'
)
## Combined (ANDed together)
results = client.get(
filters={
'status': {'_eq': 'published'},
'category': {'_in': [33, 37]},
'published_at': {'_not_null': True}
},
collection='Blog_Posts'
)Multi-Level Relation Expansion
With dot-notation, you can fetch nested relations in one call:
## Fetch posts with author, category, and tags
records = client.get_expanded(
fields=[
'id', 'title', 'status',
'author.id', 'author.name', 'author.email', # M2O relation
'category.id', 'category.name', # M2O relation
'tags.id', 'tags.name', 'tags.slug' # M2M via junction
],
collection='Blog_Posts',
limit=20
)
## Result: each record has nested dicts like {'author': {'id': 1, 'name': 'Alex'}, 'tags': [{'id': 5, 'name': 'AI'}, ...]}JSON Fields for Flexible Data
Use json type for arbitrary structured data:
client.insert({
'shipping_address': {
'street': '123 Tech Blvd',
'city': 'Dubai',
'country': 'UAE',
'postal_code': '00000'
},
'metadata': {
'utm_source': 'newsletter',
'campaign_id': 'Q2-2026-ai-launch'
}
}, collection='Orders')The JSON is stored natively and queryable with filters on nested keys.
The Admin API: When You Need to Touch Schema Programmatically
The skill exposes create_collection() and create_field() through the Directus admin REST API (/collections and /fields/{collection}), not GraphQL.
Admin token required. Your DIRECTUS_API_TOKEN is for CRUD; DIRECTUS_ADMIN_TOKEN is for schema operations.
client = DirectusClient(
url=os.getenv('DIRECTUS_URL'),
token=os.getenv('DIRECTUS_API_TOKEN'), # for CRUD
admin_token=os.getenv('DIRECTUS_ADMIN_TOKEN') # for schema
)
## Create a new collection
client.create_collection('TEST_Items', fields=[
{'field': 'id', 'type': 'integer', 'schema': {'is_primary_key': True, 'has_auto_increment': True}, 'meta': {'hidden': True, 'interface': 'numeric', 'readonly': True}},
{'field': 'name', 'type': 'string', 'schema': {}, 'meta': {'interface': 'input'}},
{'field': 'value', 'type': 'integer', 'schema': {}, 'meta': {'interface': 'numeric'}}
])
## Add a field to existing collection (use with caution - may corrupt GraphQL schema)
client.create_field('TEST_Items', 'description', 'text', meta={'interface': 'textarea'})
## List all collections
collections = client.get_collections()
print(collections) # ['Blog_Posts', 'Blog_Authors', ...]
## Get collection schema
schema = client.get_collection_schema('Blog_Posts')
print(schema['fields'].keys()) # dict_keys(['id', 'status', 'author', ...])Warning: Incrementally adding fields via create_field() can corrupt the GraphQL schema for that collection, causing all subsequent GraphQL queries to fail with 400. The safest pattern: define every field up front in create_collection().
Putting It All Together: Your First Vibe-Code Session
Here's what a complete workflow looks like:
## 1. Start your terminal (with Directus tokens in env)
$ export DIRECTUS_URL="https://hub.aratech.ae"
$ export DIRECTUS_API_TOKEN="sZk-st..." # user token
$ export DIRECTUS_ADMIN_TOKEN="ad-..." # admin token
## 2. Launch Hermes agent
$ hermes
## 3. Give the prompt (copy-paste from above)
You: Build me a blog backend with Directus. Create these collections: ...
## 4. Agent thinks, generates script, executes
🤖 Agent: Creating Blog_Authors... ✅
Creating Blog_Categories... ✅
Creating Blog_Tags... ✅
Creating Blog_Posts... ✅
Creating Blog_Posts_Translations... ✅
Creating Blog_Posts_Tags... ✅
Seeding 2 sample posts... ✅
Verification:
- Blog_Posts: 2 records
- Blog_Posts_Translations: 2 records (en only)
- Blog_Authors: 1 record
- Blog_Categories: 1 record
- Blog_Tags: 0 records
✅ Backend scaffolded successfully! Your Directus admin panel at https://hub.aratech.ae/admin now shows all collections populated.
## 5. You're done. Check the admin UI or query:
$ python3 -c "from directus.client import DirectusClient; c = DirectusClient(); print(c.get(collection='Blog_Posts', limit=3))"That's it. From blank Directus instance to a full blog backend with relationships and sample data in under a minute.
What This Enables: Rapid Prototyping at Scale
This isn't just about saving time on schema creation (though that's huge). It's about reducing the feedback loop between idea and working prototype:
- Week-long schema design → 5 minutes of describing what you want
- Manual migration files → One generated, executed script
- API spec drafting → Directus auto-generates REST and GraphQL endpoints
- Admin panel configuration → Directus admin UI is ready immediately with your collections, fields, and permissions
You can now spin up a project-specific backend for:
- Internal tools (inventory, ticketing, asset tracking)
- Customer portals (order history, support tickets)
- SaaS data models (subscriptions, tenants, usage logs)
- MVPs for client work (describe the domain, get an instant CMS-backed backend)
And because Directus is just your database plus a smart API layer, you own the data. No vendor lock-in. Export the SQL anytime.
Limitations and Gotchas
Not All Backend Patterns Fit Directus
Directus is a CMS-first platform. It excels at:
- Content-centric models (blogs, news, docs, catalogs)
- Structured relational data (users, orders, products)
- Media-heavy apps (file management built-in)
It's not ideal for:
- High-frequency transactional systems (banking, real-time bidding)
- Complex event-sourcing or CQRS architectures
- Systems requiring custom SQL functions or triggers
- Real-time websockets (though you can use its hooks and webhooks)
You Still Need to Think About Data Modeling
Vibe-coding doesn't replace understanding your domain. Garbage in, garbage out:
- Poorly named fields → confusing API
- Missing indexes → slow queries at scale
- Flat structure for deep nesting → inefficient reads
The skill scaffolds based on your spec. Good specs come from good modeling.
Permissions Still Need Configuration
The skill creates collections and fields as an admin, but you still need to configure Directus permissions (Roles & Policies) to expose the right data to frontend users. The skill doesn't touch permissions yet.
Large Content Requires Patching
If you're publishing markdown articles >1500 characters, always use the POST-then-PATCH pattern to avoid truncation. The skill doesn't auto-PATCH yet - you need to handle it in your seeding script.
Conclusion: The Backend Is Now a Conversation
Building a backend used to mean writing SQL migrations, configuring an ORM, building controller routes, and wiring up an admin. Days or weeks of work.
In 2026, with Directus and the nikola66 skill, it's:
- Describe your schema in plain English
- Let the agent generate and execute the Python
- Your backend is live
We've used this pattern to spin up three project backends this month alone. What used to be a blocker - "we need to build the data layer first" - is now a non-issue. The bottleneck moved upstream: now we spend our time on product logic and UX, not plumbing.
The vibe-coding era is here. Your backend is ready when you are.
Next Steps
- Install the skill:
npx skills add nikola66/directus-skill - Connect to Directus: Set
DIRECTUS_URL,DIRECTUS_API_TOKEN,DIRECTUS_ADMIN_TOKEN - Try the blog example above (it creates a fully queryable multilingual-ready schema)
- Extend it: Add collections for comments, likes, subscriptions, webhook logs
- Hook up permissions in Directus Admin → Settings → Roles & Policies
Got questions? Drop us a line at Telegram @Hermdroid or open an issue on the skill repo. Happy building.
Reading time: ~9 minutes Category: Tutorial Tags: Directus, Backend Development, AI Agents, Vibe Coding, CMS, GraphQL, Python, Database Schema, API, No-Code