Elyra
Elyra The coding agent e The native code editor Elyra Grove Native local development environment Askr The real server for Laravel & PHP Elyra Framework Rust + Svelte 5 framework for desktop apps Elyra Conductor Local project conductor Elyra SQL Server MySQL-compatible SQL server in Rust Elyra Félagi Agents as teammates on one board Elyra SQL Client Native desktop SQL workbench Elyra SQL Anywhere Replication-ready SQL engine Elyra Sjá SEO & GEO workspace for macOS Elyra DataGrid Server-driven data grid for Laravel
Start here
Concepts
Elyra

Data model

Fifteen tables. The shape that matters most is the polymorphic actor, which appears in five of them.


The polymorphic actor

Anywhere the application records who did this, the columns are a type and an id:

Table Columns
issues assignee_type + assignee_id, creator_type + creator_id
whiteboards / whiteboard_elements One row per thing on a surface. visibility private or workspace; issue_id is the wire back to the data model
articles Knowledge base. parent_id self-referencing, path to the document on disk, search_text a plain-text cache of it
time_entries actor_type + actor_id — an agent's hour is an hour
comments author_type + author_id
activities actor_type + actor_id (nullable — the system has no id)
projects lead_type + lead_id
tasks initiator_type + initiator_id

A morph map keeps user and agent in the database rather than fully qualified class names, so the columns stay readable and a namespace refactor does not rewrite data:

Relation::enforceMorphMap(['user' => User::class, 'agent' => Agent::class]);

This is the design the whole product rests on. It is why an agent can be assigned work, create issues, comment and appear in the audit log through exactly the same API as a person — and why there is no "run AI" button anywhere in the interface.


Tables

Identity and tenancy

Table Notable columns
users is_admin, email_verified_at, seen_version, email_notifications, two_factor_*, identity_provider_id + external_subject (unique — matched on the provider's sub, never on email) — the first two guarded
workspace_invitations email, role, token_hash, expires_at, accepted_at
workspaces slug, issue_prefix, issue_counter, context, repositories
workspace_members role, weekly_hours
identity_providers issuer, client_id, client_secret (encrypted), domains, enforced, satisfies_two_factor — platform-wide, because an account belongs to a person rather than a workspace
ip_allowlist_entries label, range, last_matched_at — platform-wide, not per workspace
daemon_tokens token_hash, token_prefix, last_used_at, revoked_at

Work

Table Notable columns
projects status, priority, lead_*, target_date, enabled_issue_types
issues cycle_id (nullOnDelete — deleting a window must not delete the work), carried_over (how many closed windows it has survived), source + external_key (unique per workspace — a second import updates), number, type, status, priority, parent_id, project_id, position, start_date, due_date, estimate_minutes, spent_minutes
comments parent_id, source_task_id, body
activities action, properties, task_id, issue_id
whiteboards / whiteboard_elements One row per thing on a surface. visibility private or workspace; issue_id is the wire back to the data model
articles Knowledge base. parent_id self-referencing, path to the document on disk, search_text a plain-text cache of it
time_entries actor_*, minutes, spent_on, source, task_id
issue_subscriber user_id, source — including opted_out
inbox_items type, summary, actor_*, read_at, archived_at
issue_dependencies predecessor_id, successor_id, type, lag_days
imports source, filename, column_map, value_map, counts, warnings
webhooks url, secret, events, consecutive_failures, disabled_at
attachments attachable_type + attachable_id (morph, so nothing cascades — deletion is explicit in the model), path never derived from the uploaded name, checksum
whiteboards / whiteboard_elements One row per thing on a surface. visibility private or workspace; issue_id is the wire back to the data model
articles Knowledge base. parent_id self-referencing, path to the document on disk, search_text a plain-text cache of it
time_entries minutes, spent_on, actor morph, source — the truth under issues.spent_minutes, which is a cached sum
cycles number, starts_on, ends_on, closed_at — generated, never created by hand
labels / issue_label name unique per workspace, color a token name rather than a hex
artifacts type, state, url, url_hash, reference, title, task_id, issue_id

Execution

Table Notable columns
agents provider, runtime_id, instructions, custom_env, custom_args, mcp_config, max_concurrent_tasks, visibility, archived_at
runtimes ulid, daemon_id, provider, last_seen_at, client_capabilities
tasks ulid, status, session_id, work_dir, lease_expires_at, attempts, cancel_requested_at
task_messages seq, type, tool, content, payload

Knowledge

Table Notable columns
skills slug, content
skill_files path, content
agent_skill enabled

Automation and reporting

Table Notable columns
autopilots trigger, cron_expression, timezone, next_run_at, webhook_token_hash, issue_title
autopilot_runs trigger, status, reason, payload, issue_id, task_id
saved_reports report, name, filters, visibility
scenarios name, visibility, status, applied_at, applied_by
scenario_proposals subject_*, field, value, baseline

Decisions worth knowing

Issue numbers are reserved under a row lock. Workspace::reserveIssueNumber() locks the workspace row inside a transaction, so two people creating issues at the same instant cannot claim ACME-42 twice.

Positions are fractional. Dropping a card between two others sets its position to the midpoint, so a move never renumbers a column.

Public identifiers are ULIDs. runtimes and tasks carry a ulid beside their auto-increment key. Daemon endpoints address the ULID, so internal ids never leave the server.

Deletions preserve work. Deleting a project nulls issues.project_id; deleting an epic nulls its children's parent_id; removing a member deletes the membership, not the account. Agents are archived rather than deleted, because their name appears on history that must keep making sense.

spent_minutes is a cache, not a source. The truth is one row per piece of work in time_entries, each with an actor, a date and a source. A single integer on the issue can be shown and cannot answer who or when, which is the only thing a time report ever asks. The cache exists so an issue page costs one query.

No column may be named after an Eloquent internal. PHP lets a sibling subclass read another instance's protected members directly, so a column called original, previous or changes is read out of the framework's dirty-tracking arrays instead of the database — silently, and only from inside another model. A test walks every model's table and fails on any of them.

Sequence numbers make the output stream idempotent. task_messages is unique on (task_id, seq), so a daemon retrying after a network failure cannot double a line.

JSON columns have no database defaults. MySQL 8 refuses them. Defaults live in each model's $attributes, which also means a freshly created model answers questions about itself correctly before it has been read back — exactly when an observer asks.


Why artifacts are a table

They could have been a key inside tasks.result, which is already a JSON column and already free-form. One requirement settles it: the return path looks an artifact up by URL. A webhook saying "this pull request merged" arrives with nothing but a URL and a state, and has to find the issue it belongs to. A JSON column cannot be indexed for that question.

url_hash exists because the column it summarises cannot carry an index either: MySQL caps an index key at 3072 bytes, and a utf8mb4 varchar(2048) is 8192. The hash is of a normalised URL — scheme and host lowercased, trailing slash removed — so the same pull request written two ways still matches.

issue_id is denormalised from the task. An artifact is read from the issue far more often than from the run that produced it, and the issue page should not walk every task to find its own links.