Skip to main content

New Oracle Cloud Infrastructure Provider Available

ยท 7 min read
Technologist and Cloud Consultant

We've released a new StackQL provider for Oracle Cloud Infrastructure:

  • oci - the OCI control plane across identity, compute, networking, storage, database, containers, security, observability and cost services: identity, compute, network, block_storage, object_storage, database, container_engine, load_balancer, dns, kms, vault, secrets, monitoring, logging, events, functions, resource_manager, streaming, budgets, usage, audit and work_requests (22 services, 452 resources, 1,544 operations)

This completes StackQL's hyperscaler coverage alongside aws, azure and google: the same SQL surface now spans all four clouds for inventory, audit and FinOps queries.

The provider covers the full lifecycle on the tier-1 services: SELECT across the estate, INSERT, UPDATE and DELETE on VCNs, subnets, instances, volumes, buckets, databases and policies, and EXEC for actions such as instance power actions and Autonomous Database start and stop. Columns and WHERE/INSERT keys are snake_case over OCI's camelCase wire format, nested detail objects are JSON columns addressed with json_extract, and SQL LIMIT pushes down to the OCI limit query parameter.

ServiceDescription
identityCompartments, users, groups, policies, dynamic groups, domains
computeInstances, images, instance pools and configurations
networkVCNs, subnets, security lists, NSGs, gateways, route tables
block_storageVolumes, backups, volume groups
object_storageNamespaces, buckets, object metadata, preauthenticated requests
databaseDB systems, Autonomous Databases, backups
container_engineOKE clusters and node pools
load_balancerLoad balancers, backend sets, listeners
usageCost and usage summaries, carbon emissions
budgetsBudgets, alert rules, cost anomaly monitors
auditAudit events
kms, vault, secretsKey management, secret management, secret retrieval
dns, monitoring, logging, events, functions, resource_manager, streaming, work_requestsThe remaining tier-1 services

Connectโ€‹

OCI API requests are signed with an API key (the draft-cavage HTTP signature scheme). The signing is built into the engine as the oci_signing_v1 auth type, available from stackql v0.12.732, so there is nothing to install beyond stackql itself. The provider reads the same API key credential the OCI CLI and Terraform use:

export OCI_TENANCY=ocid1.tenancy.oc1..aaaa...
export OCI_USER=ocid1.user.oc1..aaaa...
export OCI_FINGERPRINT=aa:bb:cc:...
export OCI_KEY_FILE=~/.oci/oci_api_key.pem
export OCI_REGION=ap-sydney-1
stackql shell

These names are the provider's defaults, so no --auth argument is needed. With none of them set, the standard ~/.oci/config file is read (DEFAULT profile), which is the file the OCI CLI and Terraform already share, so an environment configured for either tool works unchanged. A --auth context can point the *_env_var keys at other names (Terraform's TF_VAR_*, for example) or at a different config file and profile; a runtime context always wins over the defaults. OCI_REGION resolves the regional service endpoints and can be overridden per query in the WHERE clause.

The compartment scope patternโ€‹

Nearly every list operation in OCI is scoped to a compartment, so compartment_id is the universal WHERE key. The tenancy OCID is itself a compartment ID (the root), and the identity.compartments resource enumerates the compartments beneath it, the natural driving table for estate-wide joins:

SELECT id, name, lifecycle_state
FROM oci.identity.compartments
WHERE compartment_id = 'ocid1.tenancy.oc1..aaaa...';

Compute and network estate queriesโ€‹

Nested details come back as JSON columns, one json_extract away. Instance shape configuration is the usual example:

SELECT
display_name,
shape,
json_extract(shape_config, '$.ocpus') AS ocpus,
json_extract(shape_config, '$.memoryInGBs') AS memory_gb,
lifecycle_state
FROM oci.compute.instances
WHERE compartment_id = 'ocid1.compartment.oc1..aaaa...';
display_nameshapeocpusmemory_gblifecycle_state
web-1VM.Standard.E2.1.Micro11RUNNING

The same pattern applies across the network estate. VCNs, subnets, security lists and network security groups are all queryable per compartment and joinable on OCIDs:

SELECT display_name, cidr_block, dns_label, lifecycle_state
FROM oci.network.vcns
WHERE compartment_id = 'ocid1.compartment.oc1..aaaa...';

IAM auditโ€‹

Policy statements are a column, so a tenancy-wide policy review is one query:

SELECT name, description, statements
FROM oci.identity.policies
WHERE compartment_id = 'ocid1.tenancy.oc1..aaaa...';

and so is the user review that usually follows it:

SELECT name, email, is_mfa_activated, last_successful_login_time
FROM oci.identity.users
WHERE compartment_id = 'ocid1.tenancy.oc1..aaaa...'
AND is_mfa_activated = 0;

Provision, mutate and tear downโ€‹

Mutations are the usual SQL verbs. Request body attributes use the same snake_case names as the columns:

-- create
INSERT INTO oci.network.vcns (compartment_id, cidr_block, display_name)
SELECT 'ocid1.compartment.oc1..aaaa...', '10.0.0.0/16', 'my-vcn';

-- partial update
UPDATE oci.network.vcns
SET display_name = 'my-vcn-renamed'
WHERE vcn_id = 'ocid1.vcn.oc1.ap-sydney-1.aaaa...';

-- remove it
DELETE FROM oci.network.vcns
WHERE vcn_id = 'ocid1.vcn.oc1.ap-sydney-1.aaaa...';

Actions map to EXEC, with the wire parameter names:

EXEC oci.compute.instances.instance_action
@instanceId = 'ocid1.instance.oc1.ap-sydney-1.aaaa...',
@action = 'STOP';

Mutating operations return an opc-work-request-id, and the work_requests service exposes the work request and its log for polling.

FinOps across four cloudsโ€‹

Cost governance surfaces are first-class resources. Budget versus actual is a SELECT:

SELECT
display_name,
amount,
actual_spend,
forecasted_spend,
alert_rule_count
FROM oci.budgets.budgets
WHERE compartment_id = 'ocid1.tenancy.oc1..aaaa...'
ORDER BY actual_spend DESC;

The usage API is a POST, so a cost-by-service summary is an EXEC whose request body is the same JSON the API takes:

EXEC oci.usage.usages.request_summarized_usages
@@json = '{
"tenantId": "ocid1.tenancy.oc1..aaaa...",
"timeUsageStarted": "2026-09-01T00:00:00Z",
"timeUsageEnded": "2026-10-01T00:00:00Z",
"granularity": "MONTHLY",
"groupBy": ["service"]
}';

and the audit trail is a time-bounded query against audit.events:

SELECT
event_time,
event_type,
json_extract(data, '$.identity.principalName') AS principal,
json_extract(data, '$.resourceName') AS resource
FROM oci.audit.events
WHERE compartment_id = 'ocid1.tenancy.oc1..aaaa...'
AND start_time = '2026-10-01T00:00:00Z'
AND end_time = '2026-10-06T00:00:00Z';

The query this provider exists for is the four-hyperscaler estate in one statement:

SELECT 'oci' AS provider, display_name AS name, shape AS size, lifecycle_state AS state
FROM oci.compute.instances
WHERE compartment_id = 'ocid1.compartment.oc1..aaaa...'
UNION ALL
SELECT 'aws', instance_id, instance_type, json_extract(state, '$.name')
FROM aws.ec2.instances
WHERE region = 'us-east-1'
UNION ALL
SELECT 'azure', name, json_extract(properties, '$.hardwareProfile.vmSize'), json_extract(properties, '$.provisioningState')
FROM azure.compute.virtual_machines
WHERE subscriptionId = '00000000-0000-0000-0000-000000000000' AND resourceGroupName = 'my-rg'
UNION ALL
SELECT 'google', name, machineType, status
FROM google.compute.instances
WHERE project = 'my-project' AND zone = 'us-central1-a';

The same union shape applies to cost data, giving a four-cloud spend picture from one query, in one tool, with one audit log.

Scopeโ€‹

This release ships the tier-1 services listed above. OCI has dozens more (AI services, integration, analytics, GoldenGate and the rest of the long tail); those are catalogued and will be added per release cycle. Two things are deliberately out of scope: the object storage data plane (object bodies are streaming operations; object listing and metadata are in) and the per-vault KMS crypto and management endpoints, which live on dedicated hosts the provider cannot route to today. One known engine limit: list operations that paginate with OCI's opc-next-page response header currently return the first page, sized by LIMIT up to the service maximum, while body-token lists such as object listing paginate fully; the engine fix is tracked and the provider needs no change when it lands.

Get startedโ€‹

The provider needs stackql v0.12.732 or later. Pull it from the public registry:

registry pull oci;

Provider docs are at oci-provider.stackql.io. Let us know what you build. Star us on GitHub.

New TypeSafe Provider Available

ยท 13 min read
Technologist and Cloud Consultant

We've released a new StackQL provider for TypeSafe AI:

  • typesafe - the TypeSafe API: systemone (the evaluation endpoint behind Jev, TypeSafe's flagship model) and models (the models and aliases an API key can use) - 2 services, 2 resources, 2 operations, both SELECT

Jev is a System One model. Instead of generating text it answers typed questions about a state you supply and returns a calibrated probability or distribution for each one: a Noul question is a yes/no with the probability of yes, a Choice picks one option from a set you define with a probability per option and a confidence, and a Score rates the state against an ordered rubric. Any mix of the three runs against the same state in one request, in about a hundred milliseconds, for a fraction of a cent. There is nothing to parse and nothing to coax into JSON.

That matters most for agents that already run on the StackQL MCP server. Those agents have every provider's control plane as SQL, and the audit log and approval gates that come with it. What a WHERE clause cannot do is make the judgment call: is this instance production or scratch, does this rule's description justify public ingress, does this account still need an admin role, is a restart the right response to this event. In this provider that call is a SELECT too, so a decision sits between a context query and a mutation as one more governed tool call. The rest of this post is that routine, in the shapes platform, FinOps, SRE and security teams actually run.

Connectโ€‹

Authentication is a TypeSafe API key, created in the TypeSafe console and read from TYPESAFE_API_KEY, the same variable the TypeSafe SDKs use (there is no Terraform provider for TypeSafe):

export TYPESAFE_API_KEY=...
stackql shell

Pricing is per input token ($0.042 per million at the time of writing; output tokens are free), and the usage column of every row reports what a decision cost. Jev 1.13 allows 80 requests per second and 100K tokens per second; the provider carries the retry policy the vendor SDKs apply by default, so a 429 or a 529 on the first attempt is retried with backoff before it surfaces.

The routineโ€‹

An evaluation is a SELECT from typesafe.systemone.evaluations: the state, the model and the questions are the WHERE clause, and the row that comes back carries the versioned model that answered, the answers keyed by the names you chose, and the token usage. Over MCP the routine is three tool calls:

  1. Context - run_select_query against the provider that owns the facts (aws, k8s, okta, github, ...). The agent keeps the columns the decision needs.
  2. Decision - run_select_query against typesafe.systemone.evaluations, with the record serialised as the JSON state and the routine's questions as questions.
  3. Action - run_mutation_query or run_lifecycle_operation against the owning provider, only when the answer clears the threshold the routine sets: a Noul at or above 0.9 (or at or below 0.1), a Choice with confidence at or above 0.9, a Score above a level.

Every evaluation is a read, so the decision step is allowed in every MCP server mode, including read_only; in the default safe mode the action still goes through the client's approval prompt, and in read_only the routine stops at the recommendation. Every tool call lands in the server's audit log, and because the row names the model version that answered, every automated change can record which model decided it.

The examples below were run live against the API before publication. Each shows the context query, the decision over one representative record, and the action; the answer Jev gave for that record is under each block.

Tagging hygieneโ€‹

Platform engineering: classify an instance from its name and existing tags, then write the missing tags back.

-- 1. context (run_select_query)
SELECT instance_id, instance_type, launch_time, tags
FROM aws.ec2.instances
WHERE region = 'us-east-1';

-- 2. decision (run_select_query): one record from the result as the state
SELECT json_extract(answers, '$.environment.choice') AS environment,
json_extract(answers, '$.environment.confidence') AS confidence,
json_extract(answers, '$.owner_team.choice') AS owner_team,
model
FROM typesafe.systemone.evaluations
WHERE state = '{"instance_id": "i-0a1b2c3d4e5f67890", "instance_type": "m5.2xlarge", "launch_time": "2026-03-02T09:14:00Z", "tags": [{"Key": "Name", "Value": "jenkins-agent-prod-2"}, {"Key": "created-by", "Value": "ci-platform@example.com"}]}'
AND model = 'jev-latest'
AND questions = '{
"environment": {"type": "choice", "instructions": "Which environment does this instance belong to? Use the Name tag and anything else in the record.",
"criteria": {"production": "Serves live traffic or production pipelines", "staging": "Pre-production, UAT or release testing", "development": "Developer, sandbox or test use", "unknown": "The record does not say"}},
"owner_team": {"type": "choice", "instructions": "Which team most likely owns this instance?",
"criteria": {"platform": "CI, build and shared infrastructure", "data": "Analytics and data pipelines", "product": "Customer-facing applications", "unknown": "Cannot tell from the record"}}
}';

-- 3. action (run_mutation_query), when confidence >= 0.9
INSERT INTO aws.ec2.tags (ResourceId, Tag, region)
SELECT 'i-0a1b2c3d4e5f67890',
'[{"Key": "environment", "Value": "production"}, {"Key": "owner", "Value": "platform"}]',
'us-east-1';
environmentconfidenceowner_teammodel
production1.0platformjev-1.13.0

A routine that walks every untagged instance in the account is a loop over the context rows: one decision call per record, one tag write per record that clears the bar, and the rest left for a person.

Rightsizing and off-hours schedulingโ€‹

FinOps and GreenOps: given an instance record and the utilisation the agent has gathered, decide between keeping it, stopping it outside business hours, downsizing it or referring it to the owner. A second question asks whether the workload could run at another time or in another region at all, which is the lever carbon-aware scheduling needs.

-- 1. context (run_select_query)
SELECT instance_id, instance_type, state, launch_time, tags
FROM aws.ec2.instances
WHERE region = 'us-east-1';

-- 2. decision (run_select_query)
SELECT json_extract(answers, '$.action.choice') AS action,
json_extract(answers, '$.action.confidence') AS confidence,
json_extract(answers, '$.schedulable.noul') AS p_schedulable,
model
FROM typesafe.systemone.evaluations
WHERE state = '{"instance_id": "i-0b9c8d7e6f5a43210", "instance_type": "r5.4xlarge", "state": "running", "launch_time": "2025-11-18T03:00:00Z", "tags": [{"Key": "Name", "Value": "nightly-report-builder"}, {"Key": "owner", "Value": "data"}], "avg_cpu_14d_pct": 3.1, "max_cpu_14d_pct": 61.0, "busy_hours_utc": "01:00-03:00"}'
AND model = 'jev-latest'
AND questions = '{
"action": {"type": "choice", "instructions": "What should a cost review do with this instance?",
"criteria": {"keep": "Utilisation and purpose justify running it as is", "stop_outside_hours": "It only works in a known window and can be stopped the rest of the time", "downsize": "It is oversized for its sustained load", "review_with_owner": "The record is not enough to decide"}},
"schedulable": {"type": "noul", "instructions": "Could this workload run at a different time of day or in a different region without affecting users?",
"criteria": {"true": "A batch or scheduled job with no interactive users", "false": "Serves users or other systems on demand"}}
}';

-- 3. action (run_lifecycle_operation), when action = 'stop_outside_hours' and confidence >= 0.9
EXEC aws.ec2.instances.stop_instances
@InstanceId = 'i-0b9c8d7e6f5a43210',
@region = 'us-east-1';
actionconfidencep_schedulable
stop_outside_hours0.90.87

The confidence sat between 0.86 and 0.91 across repeated runs of the same record, which is the point of putting a threshold in the routine rather than in the prompt: at 0.86 this instance goes to the owner instead of being stopped.

Incident triageโ€‹

SRE: read the cluster's warning events, let Jev name the likely cause, rate the severity and say whether rescheduling the pod is the right first response.

-- 1. context (run_select_query)
SELECT json_extract(involved_object, '$.namespace') AS namespace,
json_extract(involved_object, '$.kind') AS kind,
json_extract(involved_object, '$.name') AS name,
reason, message, count, last_timestamp
FROM k8s.core.events_all_namespaces
WHERE type = 'Warning';

-- 2. decision (run_select_query)
SELECT json_extract(answers, '$.cause.choice') AS cause,
json_extract(answers, '$.severity.score') AS severity,
json_extract(answers, '$.reschedule_helps.noul') AS p_reschedule_helps,
model
FROM typesafe.systemone.evaluations
WHERE state = '{"namespace": "payments", "kind": "Pod", "name": "checkout-api-7c9d6f8b5-xk2lp", "reason": "BackOff", "message": "Back-off restarting failed container checkout-api in pod checkout-api-7c9d6f8b5-xk2lp", "count": 14, "last_timestamp": "2026-10-05T02:41:07Z", "recent_log_tail": "FATAL: connection pool exhausted after 30s waiting for a connection to payments-db"}'
AND model = 'jev-latest'
AND questions = '{
"cause": {"type": "choice", "instructions": "What is the most likely cause of this event?",
"criteria": {"crash_loop": "The container exits repeatedly on its own error", "out_of_memory": "Killed for exceeding its memory limit", "image_pull": "The image cannot be pulled", "scheduling": "No node can place the pod", "dependency": "A dependency the container needs is unavailable or saturated", "other": "None of the above"}},
"severity": {"type": "score", "instructions": "How severe is this for users?",
"criteria": ["Informational, no user impact", "Degraded, users may notice", "Outage of a user-facing capability", "Outage with data or payment risk"]},
"reschedule_helps": {"type": "noul", "instructions": "Would deleting the pod so the controller reschedules it most likely resolve this?",
"criteria": {"true": "A transient or node-local fault that a fresh pod clears", "false": "A code, configuration or dependency fault a new pod would hit again"}}
}';

-- 3. action (run_mutation_query), when p_reschedule_helps >= 0.9 and severity < 2
DELETE FROM k8s.core.pods
WHERE namespace = 'payments' AND name = 'checkout-api-7c9d6f8b5-xk2lp';
causeseverityp_reschedule_helps
dependency2.20.21

This one is worth reading closely. The event alone looks like a crash loop, and a reflexive runbook would delete the pod. Jev read the log tail, named the saturated database pool as the cause, rated it an outage with payment risk and gave the restart a 0.21, so the routine does not touch the pod and the agent escalates instead.

Public ingress reviewโ€‹

CSPM: list the security group rules open to the internet, let Jev judge whether each rule's description and port make the exposure a deliberate public endpoint or an exposed management or database port, then revoke a rule only when it is judged unjustified.

-- 1. context (run_select_query)
SELECT security_group_rule_id, group_id, ip_protocol, from_port, to_port, cidr_ipv_4, is_egress, description
FROM aws.ec2.security_group_rules
WHERE region = 'us-east-1'
AND cidr_ipv_4 = '0.0.0.0/0';

-- 2. decision (run_select_query)
SELECT json_extract(answers, '$.justified.noul') AS p_justified,
json_extract(answers, '$.exposure.choice') AS exposure,
json_extract(answers, '$.exposure.confidence') AS confidence,
model
FROM typesafe.systemone.evaluations
WHERE state = '{"security_group_rule_id": "sgr-0f1e2d3c4b5a69788", "group_id": "sg-0123456789abcdef0", "group_name": "analytics-db", "ip_protocol": "tcp", "from_port": 5432, "to_port": 5432, "cidr_ipv_4": "0.0.0.0/0", "is_egress": false, "description": "temp access for vendor demo"}'
AND model = 'jev-latest'
AND questions = '{
"justified": {"type": "noul", "instructions": "Do the description, group name and port justify ingress from the whole internet?",
"criteria": {"true": "A deliberate public endpoint such as a load balancer or web tier on 80 or 443", "false": "A management, database or internal service port, or a temporary exception"}},
"exposure": {"type": "choice", "instructions": "What kind of exposure is this?",
"criteria": {"public_endpoint": "Intended internet-facing service", "management_port": "SSH, RDP, WinRM or similar", "database_port": "A database or cache listener", "unknown": "Cannot tell from the record"}}
}';

-- 3. action (run_lifecycle_operation), when p_justified <= 0.1
EXEC aws.ec2.security_groups.revoke_security_group_ingress
@GroupId = 'sg-0123456789abcdef0',
@SecurityGroupRuleId = 'sgr-0f1e2d3c4b5a69788',
@region = 'us-east-1';
p_justifiedexposureconfidence
0.04database_port1.0

Audit findingsโ€‹

Audit and compliance: the context comes from one provider and the action goes to another. Read the privilege grants from the identity provider's system log, let Jev check each against the change policy written into the question, and open a tracked finding for the ones that fall outside it.

-- 1. context (run_select_query)
SELECT published, eventType, displayMessage, outcome, actor, target
FROM okta.logs.system_log_events
WHERE subdomain = 'my-org'
AND since = '2026-10-04T00:00:00Z'
AND filter = 'eventType eq "user.account.privilege.grant"';

-- 2. decision (run_select_query)
SELECT json_extract(answers, '$.in_policy.noul') AS p_in_policy,
json_extract(answers, '$.risk.score') AS risk,
model
FROM typesafe.systemone.evaluations
WHERE state = '{"published": "2026-10-04T22:47:13Z", "eventType": "user.account.privilege.grant", "displayMessage": "Grant user privilege", "outcome": "SUCCESS", "actor": {"alternateId": "j.doe@example.com", "type": "User"}, "target": [{"alternateId": "svc-reporting@example.com", "type": "User"}, {"displayName": "Super Administrator", "type": "ROLE"}], "note": "no change ticket referenced"}'
AND model = 'jev-latest'
AND questions = '{
"in_policy": {"type": "noul", "instructions": "Does this privilege grant comply with the change policy?",
"criteria": {"true": "Granted by an identity administrator, between 09:00 and 17:00 UTC on a weekday, with a change ticket referenced", "false": "Outside the change window, by a non-administrator, to a service account, or without a ticket"}},
"risk": {"type": "score", "instructions": "How much risk does this grant carry?",
"criteria": ["Routine, scoped role", "Elevated role to a person", "Tenant-wide administrative role, or any role to a service account"]}
}';

-- 3. action (run_mutation_query), when p_in_policy <= 0.2
INSERT INTO github.issues.issues (owner, repo, title, body, labels)
SELECT 'my-org',
'security-audit',
'Privilege grant outside change policy: svc-reporting@example.com granted Super Administrator',
'Granted 2026-10-04T22:47:13Z by j.doe@example.com with no change ticket referenced. Flagged by jev-1.13.0 (in_policy 0.02, risk 2.0). Review and revoke or document.',
'["compliance", "iam"]';
p_in_policyrisk
0.022.0

The policy lives in the criteria of one question. Changing the change window or the ticket rule is a diff in that text, reviewed like any other change to the routine, not a retrained classifier.

Writing the questionsโ€‹

Two things came out of putting these routines together. First, ask Jev the judgment and compute the facts yourself. An access-review draft asked "Is this account dormant?" with a 90-day rule in the criteria, and the answer drifted between 0.54 and 0.88 however plainly the dates were stated; Jev treats dormancy as a judgment about the person. Asked instead "Do the title and department justify the roles this account holds?" the same record answered 0.16 every time, and "What kind of account is this?" answered contractor with confidence 1.0. Days since the last sign-in is arithmetic, so it stays in SQL, and the suspension (EXEC okta.users.users.suspend_user) runs only when the arithmetic and the judgment agree. The full example is on the provider docs.

Second, keep the thresholds in the routine and the model version in the record. Choice confidences and Scores drift a little between identical calls, which is a reason to gate on them rather than to read a single number as a verdict. The model column records which version answered, so an action log can say "stopped by jev-1.13.0 at 0.91", and once a routine's thresholds are tuned the versioned id can be pinned in place of the jev-latest alias.

What the provider is and is notโ€‹

The TypeSafe API is two operations, and the provider maps both. There is no administrative or usage API to map: keys are created and revoked in the TypeSafe console, and usage is reported per request in the usage column rather than through a reporting endpoint. Everything in the API is a read, so the provider has no INSERT, UPDATE or DELETE methods of its own; the mutations in this post belong to the providers that own the resources. The models catalog is the one plain inventory query:

SELECT name, description, release_date
FROM typesafe.models.models
ORDER BY name;

Every statement in this post was checked before publication: the typesafe statements ran live, and the aws, k8s, okta and github statements were routed through their providers to the wire against a scratch account. The complete smoke run of the provider costs a fraction of a cent.

Get startedโ€‹

Pull the provider from the public registry:

registry pull typesafe;

Provider docs, including the request shape of each question type, the error contract and the full set of agent-routine examples, are at typesafe-provider.stackql.io. For the MCP server itself, start with How to use StackQL with AI agents. Let us know what you build. Star us on GitHub.

New Railway Provider Available

ยท 8 min read
Technologist and Cloud Consultant

We've released a new StackQL provider for Railway:

  • railway - the Railway public API: projects, environments, services, deployments, variables, networking, storage, billing, observability, workspaces, account, templates, integrations, platform and agents (15 services, 151 resources, 396 operations)

The provider reads and writes. Projects, environments, services, variables, domains and volumes can be queried, created, changed and removed, and deployment operations such as redeploy, restart and rollback are available on the resources they act on. The rest of this post is the questions it answers and the tasks it handles.

Connectโ€‹

Authentication is a Railway account token or workspace token, read from RAILWAY_TOKEN - the same variable the Terraform Railway provider uses:

export RAILWAY_TOKEN=...
stackql shell

Tokens are created under Account settings -> Tokens. A project token does not work with the provider. Railway limits API requests per hour by plan (100 on Free, 1000 on Hobby, 10000 on Pro), which is worth keeping in mind for queries that cover many projects or environments.

What is deployed whereโ€‹

The workspaces a token can reach, the projects in one, and the environments of a project:

SELECT id, name, plan
FROM railway.workspaces.workspaces;

SELECT id, name, description, created_at
FROM railway.projects.projects
WHERE workspace_id = '<workspace-id>'
ORDER BY created_at DESC;

SELECT id, name, is_ephemeral
FROM railway.environments.environments
WHERE project_id = '<project-id>';

A service instance is a service as it is configured in one environment. Listing the instances of an environment shows what runs there, from which image or repository, and how the last deployment went:

SELECT service_name, region, num_replicas,
json_extract(source, '$.image') AS image,
json_extract(source, '$.repo') AS repo,
json_extract(latest_deployment, '$.status') AS last_deployment,
json_extract(latest_deployment, '$.created_at') AS deployed_at
FROM railway.services.service_instances
WHERE environment_id = '<environment-id>';

An IN list covers several environments in one query, with one request made per environment. Joining to environments puts names on the rows:

SELECT e.name AS environment, s.service_name, s.region, s.num_replicas,
json_extract(s.latest_deployment, '$.status') AS last_deployment
FROM railway.services.service_instances s
JOIN railway.environments.environments e ON e.id = s.environment_id
WHERE e.project_id = '<project-id>'
AND s.environment_id IN ('<production-environment-id>', '<staging-environment-id>')
ORDER BY environment, service_name;

The same works across projects, here for the services of two projects in a workspace:

SELECT p.name AS project, s.name AS service, s.created_at
FROM railway.services.services s
JOIN railway.projects.projects p ON p.id = s.project_id
WHERE p.workspace_id = '<workspace-id>'
AND s.project_id IN ('<project-id>', '<other-project-id>')
ORDER BY project, service;

Deployment healthโ€‹

Deployment outcomes for a project, then the ones that failed or crashed, with who triggered them:

SELECT status, count(*) AS deployments
FROM railway.deployments.deployments
WHERE project_id = '<project-id>'
GROUP BY status;

SELECT id, service_id, status, created_at,
json_extract(creator, '$.name') AS deployed_by
FROM railway.deployments.deployments
WHERE project_id = '<project-id>'
AND status = '{in: [FAILED, CRASHED]}'
ORDER BY created_at DESC;

Services in an environment whose latest deployment is not healthy:

SELECT service_name,
json_extract(latest_deployment, '$.id') AS deployment_id,
json_extract(latest_deployment, '$.status') AS status
FROM railway.services.service_instances
WHERE environment_id = '<environment-id>'
AND json_extract(latest_deployment, '$.status') NOT IN ('SUCCESS', 'SLEEPING');

From there, the build and runtime logs of a deployment are tables. LIMIT sets how many lines are fetched:

SELECT timestamp, severity, message
FROM railway.observability.build_logs
WHERE deployment_id = '<deployment-id>'
LIMIT 100;

SELECT timestamp, severity, message
FROM railway.observability.deployment_logs
WHERE deployment_id = '<deployment-id>'
AND filter = 'error'
LIMIT 100;

Drift between environmentsโ€‹

Variables are one row per variable, so comparing two environments is a join. Variables a service has in production that are missing in staging:

SELECT prod.name
FROM railway.variables.variables prod
LEFT JOIN railway.variables.variables stg
ON stg.name = prod.name
WHERE prod.project_id = '<project-id>'
AND prod.environment_id = '<production-environment-id>'
AND prod.service_id = '<service-id>'
AND stg.project_id = '<project-id>'
AND stg.environment_id = '<staging-environment-id>'
AND stg.service_id = '<service-id>'
AND stg.name IS NULL
AND prod.name NOT LIKE 'RAILWAY_%';

Variables set in both environments with different values:

SELECT prod.name
FROM railway.variables.variables prod
JOIN railway.variables.variables stg
ON stg.name = prod.name
WHERE prod.project_id = '<project-id>'
AND prod.environment_id = '<production-environment-id>'
AND prod.service_id = '<service-id>'
AND stg.project_id = '<project-id>'
AND stg.environment_id = '<staging-environment-id>'
AND stg.service_id = '<service-id>'
AND prod.value <> stg.value
AND prod.name NOT LIKE 'RAILWAY_%';

The variables Railway provides itself are prefixed RAILWAY_ and differ between environments by design, so they are left out. Both queries return names only. Selecting value returns the values in clear, so treat that output as a secret.

The same approach compares how a service is configured in each environment:

SELECT prod.service_name,
prod.num_replicas AS prod_replicas, stg.num_replicas AS staging_replicas,
prod.region AS prod_region, stg.region AS staging_region,
prod.start_command AS prod_start_command, stg.start_command AS staging_start_command
FROM railway.services.service_instances prod
JOIN railway.services.service_instances stg
ON stg.service_id = prod.service_id
WHERE prod.environment_id = '<production-environment-id>'
AND stg.environment_id = '<staging-environment-id>';

Domains and certificatesโ€‹

The generated domains of every service in an environment, without going service by service. The custom_domains key of the same column holds the custom ones:

SELECT s.service_name,
json_extract(d.value, '$.domain') AS domain,
json_extract(d.value, '$.target_port') AS target_port
FROM railway.services.service_instances s,
json_each(json_extract(s.domains, '$.service_domains')) d
WHERE s.environment_id = '<environment-id>';

Custom domains carry their verification and certificate state, and the DNS records Railway expects to find:

SELECT domain,
json_extract(status, '$.verified') AS verified,
json_extract(status, '$.certificate_status') AS certificate_status,
json_extract(status, '$.dns_records') AS dns_records
FROM railway.networking.custom_domains
WHERE project_id = '<project-id>'
AND environment_id = '<environment-id>'
AND service_id = '<service-id>';

Usage and costโ€‹

Usage for a workspace by measurement, grouped by project, with project names joined in:

SELECT p.name AS project, u.measurement, u.value
FROM railway.billing.usage u
JOIN railway.projects.projects p
ON p.id = json_extract(u.tags, '$.project_id')
WHERE u.workspace_id = '<workspace-id>'
AND u.measurements = '[CPU_USAGE, MEMORY_USAGE_GB, NETWORK_TX_GB, DISK_USAGE_GB]'
AND u.group_by = '[PROJECT_ID]'
AND p.workspace_id = '<workspace-id>'
ORDER BY project, measurement;

start_date and end_date narrow the window. The projected usage for the current billing period, and the billing position of the workspace:

SELECT project_id, measurement, estimated_value
FROM railway.billing.estimated_usage
WHERE workspace_id = '<workspace-id>'
AND measurements = '[CPU_USAGE, MEMORY_USAGE_GB]';

SELECT state, current_usage, credit_balance,
json_extract(billing_period, '$.end') AS period_ends,
json_extract(usage_limit, '$.hard_limit') AS hard_limit
FROM railway.billing.customers
WHERE workspace_id = '<workspace-id>';

Volumes report their allocated and used size per environment:

SELECT json_extract(volume, '$.name') AS volume, mount_path, region, state,
size_mb, current_size_mb,
ROUND(100.0 * current_size_mb / size_mb, 1) AS used_pct
FROM railway.storage.volume_instances
WHERE environment_id = '<environment-id>';

Provisioningโ€‹

A project is created with one environment. RETURNING gives back the identifiers the next statements need:

INSERT INTO railway.projects.projects (name, description, workspace_id)
SELECT 'orders', 'order processing', '<workspace-id>'
RETURNING id, name, primary_environment_id;

INSERT INTO railway.environments.environments (project_id, name)
SELECT '<project-id>', 'staging'
RETURNING id, name;

A service from a container image or a repository. A service with a source is deployed when it is created, and a running deployment is billed:

INSERT INTO railway.services.services (project_id, name, source)
SELECT '<project-id>', 'cache', '{"image": "redis:7-alpine"}'
RETURNING id, name;

INSERT INTO railway.services.services (project_id, name, source, branch)
SELECT '<project-id>', 'api', '{"repo": "my-org/orders-api"}', 'main'
RETURNING id, name;

Variables are written one at a time or as a set. Writing a variable that exists replaces its value, and skip_deploys keeps the change from triggering a deployment:

INSERT INTO railway.variables.variables
(project_id, environment_id, service_id, name, value, skip_deploys)
SELECT '<project-id>', '<environment-id>', '<service-id>', 'LOG_LEVEL', 'info', true;

INSERT INTO railway.variables.variables
(project_id, environment_id, service_id, variables, skip_deploys)
SELECT '<project-id>', '<environment-id>', '<service-id>',
'{"SENTRY_DSN": "https://example.ingest.sentry.io/1", "FEATURE_FLAGS": "checkout-v2"}', true;

A generated domain and a volume for the service:

INSERT INTO railway.networking.service_domains (environment_id, service_id)
SELECT '<environment-id>', '<service-id>'
RETURNING id, domain;

INSERT INTO railway.storage.volumes (project_id, environment_id, service_id, mount_path)
SELECT '<project-id>', '<environment-id>', '<service-id>', '/data'
RETURNING id, name;

Day-two operationsโ€‹

Changing how a service runs in an environment is an UPDATE. Values in SET are written as quoted strings:

UPDATE railway.services.service_instances
SET num_replicas = '2',
start_command = 'npm run start',
healthcheck_path = '/healthz'
WHERE service_id = '<service-id>'
AND environment_id = '<environment-id>';

Deployment operations are EXEC methods. The SHOWRESULTS hint returns the API's answer, including a refusal:

EXEC /*+ SHOWRESULTS */ railway.services.service_instances.redeploy
@service_id = '<service-id>',
@environment_id = '<environment-id>';

EXEC /*+ SHOWRESULTS */ railway.deployments.deployments.restart
@id = '<deployment-id>';

EXEC /*+ SHOWRESULTS */ railway.deployments.deployments.rollback
@id = '<deployment-id>';

Preview environment cleanupโ€‹

Pull request environments accumulate. The ephemeral environments of a project, with the pull request each one belongs to:

SELECT id, name, created_at,
json_extract(meta, '$.pr_number') AS pr_number,
json_extract(meta, '$.branch') AS branch
FROM railway.environments.environments
WHERE project_id = '<project-id>'
AND is_ephemeral = true
ORDER BY created_at;

Removing one is a DELETE. Running the listing again confirms it is gone:

DELETE FROM railway.environments.environments
WHERE id = '<environment-id>';

A join across providersโ€‹

Services deployed from a GitHub repository can be matched to the repository itself, for example to find services still deploying from a repository that has been archived or has not been pushed to in some time. This uses the github provider alongside railway:

SELECT s.service_name,
r.full_name AS repo, r.archived, r.pushed_at
FROM railway.services.service_instances s
JOIN github.repos.repos r
ON r.full_name = json_extract(s.source, '$.repo')
WHERE s.environment_id = '<environment-id>'
AND r.org = 'my-org'
ORDER BY r.pushed_at;

Get startedโ€‹

Pull the provider from the public registry:

registry pull railway;

Provider docs are at railway-provider.stackql.io. Let us know what you build. Star us on GitHub.

New GitLab Provider Available

ยท 6 min read
Technologist and Cloud Consultant

We've released a new StackQL provider for GitLab:

  • gitlab - the GitLab GraphQL API as a read-only SQL surface: projects, groups, users, issues, merge_requests, ci, work_items, security, packages, snippets, boards, analytics, audit, metadata, admin, workspaces, ml and duo (18 services, 244 resources, every one of them SELECT)

The provider is generated from the introspection schema gitlab.com publishes, pinned by content hash and refreshed as a reviewed diff. It works against gitlab.com out of the box and routes to a self-managed instance from an environment variable. It is read-only by architecture: StackQL's GraphQL path is a query path, so there are no INSERT, UPDATE, DELETE or EXEC methods. What it is for is inventory, reporting and cross-provider joins over the GitLab control plane - the questions that otherwise need a script and three API clients.

Connectโ€‹

Authentication is a personal access token with the read_api scope, read from GITLAB_TOKEN - the same variable the Terraform GitLab provider uses:

export GITLAB_TOKEN=glpat-...
stackql shell

Public projects and groups on gitlab.com are readable without a token (--auth='{"gitlab": {"type": "null_auth"}}'). For a self-managed instance, set GITLAB_HOST=gitlab.example.com and every query routes there; an explicit WHERE host = ... still wins for addressing another instance in the same session.

Three scopes, one shapeโ€‹

Resources follow the GraphQL schema. Instance-scoped resources take optional filters only (projects, users, runners); project-scoped resources are prefixed project_ and take the project path; group-scoped resources are prefixed group_ and take the group path. Columns are snake_case, and the nested identity objects GitLab attaches everywhere (author, namespace, milestone, user) are JSON columns one json_extract away.

Every project in a group tree, with activity and visibility signals:

SELECT full_path, visibility, archived,
star_count, forks_count,
open_issues_count, open_merge_requests_count,
last_activity_at
FROM gitlab.groups.group_projects
WHERE full_path = 'gitlab-org' AND include_subgroups = true
ORDER BY last_activity_at DESC;

GitLab connections are Relay-paginated with a hard page size of 100. StackQL walks the pageInfo cursor chain transparently, so the query above returns the whole tree, not the first page.

Filters are pushed downโ€‹

Every scalar or enum argument a GitLab field accepts is a WHERE parameter rendered into the GraphQL query itself, so the API does the filtering. Open merge requests with their approval state and age:

SELECT iid, title,
json_extract(author, '$.username') AS author,
draft, approved, approvals_left, detailed_merge_status,
ROUND(julianday('now') - julianday(created_at)) AS age_days
FROM gitlab.merge_requests.project_merge_requests
WHERE full_path = 'gitlab-org/gitlab-runner' AND state = 'opened'
ORDER BY age_days DESC;

Cycle time over a window (merged_after is a filter, merged_at a column):

SELECT iid, title,
ROUND((julianday(merged_at) - julianday(created_at)) * 24, 1) AS hours_to_merge
FROM gitlab.merge_requests.project_merge_requests
WHERE full_path = 'gitlab-org/gitlab-runner'
AND state = 'merged'
AND merged_after = '2026-09-01T00:00:00Z'
ORDER BY merged_at DESC;

Pipelines and runnersโ€‹

Pipeline outcomes for a project, and the same across every project in a group by joining the inventory to each project's pipelines (the engine issues one pipelines request per project):

SELECT status, count(*) AS pipelines, ROUND(AVG(duration) / 60.0, 1) AS avg_minutes
FROM gitlab.ci.project_pipelines
WHERE full_path = 'gitlab-org/gitlab-runner'
GROUP BY status;

SELECT p.full_path,
count(*) AS pipelines,
SUM(c.status = 'FAILED') AS failed,
ROUND(100.0 * SUM(c.status = 'FAILED') / count(*), 1) AS failure_pct
FROM gitlab.groups.group_projects p
JOIN gitlab.ci.project_pipelines c ON c.full_path = p.full_path
WHERE p.full_path = 'my-group'
GROUP BY p.full_path
ORDER BY failure_pct DESC;

Runner fleet status for a group, with contact recency (the instance-wide runner listing is administrator-only on gitlab.com; the group and project listings are what a token can read):

SELECT id, description, runner_type, status, paused, contacted_at, upgrade_status
FROM gitlab.ci.group_runners
WHERE full_path = 'my-group'
ORDER BY contacted_at DESC;

Vulnerability reportingโ€‹

Findings across a group by severity and state, then the unresolved criticals with their owning project:

SELECT severity, state, count(*) AS findings
FROM gitlab.security.group_vulnerabilities
WHERE full_path = 'my-group'
GROUP BY severity, state;

SELECT json_extract(project, '$.full_path') AS project,
title, report_type, detected_at, web_url
FROM gitlab.security.group_vulnerabilities
WHERE full_path = 'my-group' AND severity = 'CRITICAL' AND state = 'DETECTED'
ORDER BY detected_at;

Membership, and a join across providersโ€‹

Group membership with access level is a table:

SELECT json_extract(user, '$.username') AS username,
json_extract(user, '$.name') AS name,
json_extract(access_level, '$.string_value') AS access_level,
expires_at
FROM gitlab.groups.group_group_members
WHERE full_path = 'my-group';

which makes the offboarding question a LEFT JOIN against the identity provider - GitLab members with no active Okta user, computed locally by the SQL engine after registry pull okta:

SELECT json_extract(m.user, '$.username') AS gitlab_username,
json_extract(m.user, '$.name') AS gitlab_name,
o.status AS okta_status
FROM gitlab.groups.group_group_members m
LEFT JOIN okta.user.users o
ON lower(json_extract(o.profile, '$.login')) = lower(json_extract(m.user, '$.username') || '@example.com')
AND o.subdomain = 'my-okta-org'
WHERE m.full_path = 'my-group'
AND (o.status IS NULL OR o.status != 'ACTIVE');

How it is builtโ€‹

  • The selection set for every resource is generated by one policy from the schema: all scalar and enum fields of the node type plus a fixed allowlist of nested identity objects. The query text and the response schema come from the same field list, so DESCRIBE always matches what the wire returns.
  • GitLab enforces a query complexity limit (200 anonymous, 250 authenticated on gitlab.com). Every generated query is scored against the live limit as a build gate; the one node type that exceeded it (merge requests, at 245) was trimmed by measured per-field cost, protecting the approval and merge-state columns, to 190.
  • A handful of resolvers time out on large result sets or answer anonymous callers with a server error. Those fields are excluded by a recorded policy entry rather than left to fail whole pages.
  • all_services.csv in the repository records which schema field backs every resource and method, and regeneration fails when a mapping moves, so resource names stay stable between provider versions.

Premium-tier fields (issue weight, health status, epics) read back as null on the free tier, and the schema is gitlab.com's, so an older self-managed instance may reject fields it does not serve.

Get startedโ€‹

Pull the provider from the public registry:

registry pull gitlab;

Provider docs are at gitlab-provider.stackql.io. Let us know what you build. Star us on GitHub.

Anthropic Providers Update - September 2026

ยท 4 min read
Technologist and Cloud Consultant

We've released an update to the StackQL anthropic provider, regenerated from the current Claude API specification. The provider now covers 12 services, 27 resources and 108 operations (up from 11, 26 and 103 in the July release). Changes in this release:

  • A new dreams service
  • Files and skills moved to their generally available endpoints
  • Workspace-scoped queries on most operations through the anthropic-workspace-id parameter

The anthropic_admin provider (6 services, 11 resources, 27 operations) is unchanged in this release.

Dreamsโ€‹

Dreams are asynchronous memory-consolidation jobs: a dream reads a memory store and a set of session transcripts and writes consolidated memories into an output store. The service is a research preview on the Anthropic side, so the endpoints return a 404 for keys that are not enrolled. The dreams resource maps the surface as follows:

MethodSQL verbOperation
list, getSELECTlist dreams (cursor-paginated, walked automatically), get a dream
createINSERTstart a dream over one or more inputs
cancel, archiveEXEClifecycle operations

Dreams and their status as rows:

SELECT id, status, JSON_ARRAY_LENGTH(inputs) AS input_count, created_at, ended_at
FROM anthropic.dreams.dreams
ORDER BY created_at DESC;

Starting one is an INSERT. inputs and model are JSON values that the provider passes through as structured request fields:

INSERT INTO anthropic.dreams.dreams (inputs, model, instructions)
SELECT '[{"type": "memory_store", "memory_store_id": "memstore_01..."}]',
'{"id": "claude-opus-5"}',
'Consolidate project decisions and open questions.'
RETURNING id, status;

Files and skills on GA endpointsโ€‹

The Files API and the Skills API are generally available. The files and skills services now call the GA endpoints (/v1/files, /v1/skills) instead of the beta ones, and no longer send an anthropic-beta header. Resources, methods and SQL verbs are unchanged, so existing queries keep working. One behavioural change: the files list is now cursor-paginated and the provider walks the pages automatically.

SELECT id, filename, mime_type, size_bytes, created_at
FROM anthropic.files.files
ORDER BY created_at DESC;

SELECT id, display_name, JSON_EXTRACT(source, '$.type') AS source, latest_version_id, updated_at
FROM anthropic.skills.skills
ORDER BY updated_at DESC;

Skill versions are a separate resource keyed by the skill:

SELECT id, skill_id, name, description, created_at
FROM anthropic.skills.versions
WHERE skill_id = 'xlsx';

Uploading a file or creating a skill version is a multipart request, which SQL cannot express; those two methods remain documented as EXEC operations, and the rest of each resource (list, get, delete) is plain SQL.

Workspace-scoped queriesโ€‹

The Claude API added an optional anthropic-workspace-id header to most operations, for credentials that can act on more than one workspace. The provider exposes it as an optional parameter on around 110 operations. It is a hyphenated name, so it is double-quoted in SQL:

SELECT id, display_name, created_at
FROM anthropic.models.models
WHERE "anthropic-workspace-id" = 'wrkspc_01CZkZaBF1tNoB5wlCeusgy';

The workspace ids are the ones the anthropic_admin provider lists:

SELECT id, name, archived_at
FROM anthropic_admin.workspaces.workspaces;

A key that belongs to a single workspace can omit the parameter. The API validates the value: a malformed id is rejected with a 400.

Under the hoodโ€‹

The provider is generated from the OpenAPI specification that Anthropic now bundles with its SDKs. That specification grew from 126 to 244 operations since July, and the SDKs' configured endpoint count from 116 to 201. Beyond the changes above, the growth is the beta twins of the GA files and skills endpoints and the Admin API, which the specification now models but which needs an org-scoped admin key and belongs to the anthropic_admin provider. Every operation in the specification is either mapped or listed in a documented exclusion list, and the build fails when the two do not add up.

Every documented example query runs in the provider's test suite, and each generated docs page now shows the date it was last regenerated.

Authenticationโ€‹

Unchanged. The two providers use different key types, which are disjoint by design:

# anthropic - workspace-scoped Claude API key
export ANTHROPIC_API_KEY=sk-ant-api...

# anthropic_admin - org-scoped Admin API key (created by org admins)
export ANTHROPIC_ADMIN_KEY=sk-ant-admin...

Get startedโ€‹

Pull the latest provider from the public registry:

registry pull anthropic;

Provider docs, including required parameters and example queries for every resource, are at anthropic-provider.stackql.io and anthropic-admin-provider.stackql.io. Visit us on GitHub and let us know how you're using it.