ANTCONNECT TS/JS
With this package you can give your applications simple access to the ANT api
install npm package
npm install axios
axios is needed and not included in the package itself
npm install ant_tsconnect
this will give you access to the AntConnect class which needs a httpClient (see below)
httpclient
to connect to one of ANT's environments you need a httpclient where you set the following properties
- client_id
- client_secret
- base_url
- api_url
- this client you will pass on to AntConnect, and you can connect to the given api
const httpClient = new CustomHttpClient('http://api-antcde.io', '/api/1.0', 'X', 'SECRET')
const antConnect = newAntConnect(httpClient)
Authentication
to authenticate an user you can access the authenticate method by the following
antConnect.httpClient.authenticate({
username: '[email protected]',
password: 'password'
})
you can save the access token to localstorage or a cookie and add it with the custom interceptors to each request (see below)
two-factor authorization (2fa)
if 2fa is enabled for the user you will receive error response with data
{ code: 1707747196 }
in
antConnect.users.getAuthenticatedUser()
In that case the best way is handling this error in custom interceptor by the error code and verify your login token with the prepared request from antConnect
antConnect.users.verifyTwoFactor({ code })
interceptors
it is possible to create custom request and response interceptors.
you can create a custom class with the following functions:
// Custom request interceptor
export function requestInterceptor(config) {
config.headers = {}
config.headers['Authorization'] = 'Bearer Client'
return config
}
// Custom response interceptor
export function responseInterceptor(response) {
// Modify the response as needed
// For example, you can extract specific data or handle errors
return response
}
then you can add these interceptors to the httpclient
import { requestInterceptor } from '@/interceptors' // import
const httpClient = new CustomHttpClient(
'http://192.168.1.50:8000',
'/api/1.0',
'2',
'OEQWm6a4rsbqTSEiB2y32O2yR0J3MFlUPbZJWI9w'
)
httpClient.addRequestInterceptor(requestInterceptor) // add here
const antConnect = newAntConnect(httpClient)
create single instance
Depending on your framework there are multiple ways to create a single instance of AntConnect across your whole application
Vue (3)
// TODO change this
const httpClient = new CustomHttpClient(
'http://192.168.1.50:8000',
'/api/1.0',
'2',
'OEQWm6a4rsbqTSEiB2y32O2yR0J3MFlUPbZJWI9w'
)
httpClient.addRequestInterceptor(requestInterceptor)
const antConnect = newAntConnect(httpClient)
app.provide('antConnect', antConnect) // here we provide the instance to the whole application
and by injection in our vue components we can access the instance like this:
const antConnect = inject('antConnect')
let projects = await antConnect.projects.getProjects()
in the above example we fetch the projects of a given user.
As we have an idea to move all projects to monorepo, maybe will be better to move to pnpm packager
queryTables Examples
The queryTables method lets you query one or more tables in a single request via connect.tables.queryTables(query).
1. Select all columns
const query: TablesQuery = {
tables: [{
name: 'EMPLOYEES',
project: projectId,
as: 'employees',
columns: ['*'],
}],
}
2. Specific columns, no filter
const query: TablesQuery = {
tables: [{
name: 'EMPLOYEES',
project: projectId,
as: 'employees',
columns: [
{ name: 'first_name' },
{ name: 'last_name' },
{ name: 'department' },
],
}],
}
3. Equality condition
const query: TablesQuery = {
tables: [{
name: 'EMPLOYEES',
project: projectId,
as: 'employees',
columns: [
{
name: 'department',
conditions: [{ operator: '=', value: 'Engineering' }],
},
{ name: 'first_name' },
{ name: 'last_name' },
],
}],
}
4. IN query — match multiple values
const query: TablesQuery = {
tables: [{
name: 'EMPLOYEES',
project: projectId,
as: 'employees',
columns: [
{
name: 'status',
conditions: [{ operator: 'in', value: ['active', 'on_leave', 'probation'] }],
},
{ name: 'first_name' },
{ name: 'last_name' },
{ name: 'department' },
],
}],
}
5. Multiple conditions (AND)
const query: TablesQuery = {
tables: [{
name: 'EMPLOYEES',
project: projectId,
as: 'employees',
columns: [
{
name: 'department',
conditions: [{ operator: '=', value: 'Engineering' }],
},
{
name: 'status',
conditions: [{ operator: 'in', value: ['active', 'probation'] }],
},
{ name: 'first_name' },
{ name: 'last_name' },
],
}],
}
6. Pagination + sorting
const query: TablesQuery = {
limit: 25,
offset: 50,
tables: [{
name: 'EMPLOYEES',
project: projectId,
as: 'employees',
columns: ['*'],
sortBy: 'last_name',
sortByOrder: 'asc',
}],
}
7. Multiple tables in one request
const query: TablesQuery = {
tables: [
{
name: 'DEPARTMENTS',
project: projectId,
as: 'departments',
columns: ['*'],
},
{
name: 'EMPLOYEES',
project: projectId,
as: 'employees',
columns: [
{
name: 'status',
conditions: [{ operator: 'in', value: ['active'] }],
},
{ name: 'first_name' },
{ name: 'last_name' },
{ name: 'department' },
],
},
],
}
Reading results
const response = await connect.tables.queryTables(query)
const departments = response.departments.records // records array
const employees = response.employees.records
const totalEmployees = response.employees.stats.count
Supported condition operators
=, !=, >, <, >=, <=, like, in, not in, between, not between, is null, is not null
For in / not in, pass value as an array: value: ['a', 'b'].
For between / not between, pass a two-element array: value: [10, 50].
Task Query Parameters
Documentation for the query parameter in TaskService.getV2Tasks().
Backend: Uses Laravel Purity for filtering with Spatie QueryBuilder.
Query Structure
interface TaskQuery {
filters?: FilterObject
fields?: FieldsObject
include?: string // comma-separated relations
per_page?: number // pagination limit (default: 10)
page?: number // current page (1-indexed)
sort?: string[] // sort fields
is_task?: boolean // filter tasks vs workflows (default: true)
}
Filters
Filter Operators (Laravel Purity)
| Operator | Description | Example |
|---|---|---|
$eq | Equal | { status: { $eq: 'open' } } |
$ne | Not equal | { status: { $ne: 'closed' } } |
$gt | Greater than | { progress: { $gt: 50 } } |
$lt | Less than | { progress: { $lt: 100 } } |
$gte | Greater than or equal | { due: { $gte: '2024-01-01' } } |
$lte | Less than or equal | { due: { $lte: '2024-12-31' } } |
$in | In array (OR) | { status: { $in: ['open', 'processing'] } } |
$notIn | Not in array | { status: { $notIn: ['closed', 'canceled'] } } |
$contains | Contains substring (case-insensitive) | { title: { $contains: 'bug' } } |
$notContains | Does not contain | { title: { $notContains: 'test' } } |
$containss | Contains (case-sensitive) | { title: { $containss: 'BUG' } } |
$notContainss | Not contains (case-sensitive) | { title: { $notContainss: 'TEST' } } |
$startsWith | Starts with (case-insensitive) | { title: { $startsWith: 'task-' } } |
$startsWiths | Starts with (case-sensitive) | { title: { $startsWiths: 'TASK-' } } |
$endsWith | Ends with (case-insensitive) | { title: { $endsWith: '-urgent' } } |
$endsWiths | Ends with (case-sensitive) | { title: { $endsWiths: '-URGENT' } } |
$eqs | Equal (case-sensitive) | { title: { $eqs: 'Exact Match' } } |
$between | Between two values | { progress: { $between: [25, 75] } } |
$notBetween | Not between | { progress: { $notBetween: [0, 10] } } |
$null | Is null | { assigned_to: { $null: true } } |
$notNull | Is not null | { assigned_to: { $notNull: true } } |
$doesntHave | Relation doesn't exist | { type: { $doesntHave: true } } |
Logical Operators
| Operator | Description | Example |
|---|---|---|
$or | OR condition (array of conditions) | { $or: [{ status: { $eq: 'open' } }, { status: { $eq: 'processing' } }] } |
$and | AND condition (array of conditions) | { $and: [{ priority: { $eq: 'high' } }, { status: { $eq: 'open' } }] } |
Combining Operators
Multiple operators can be combined on the same field:
{
status: {
$in: ['open', 'processing'], // include these
$notIn: ['failed'] // but exclude this
}
}
Nested Relation Filters
Filter on related models:
{
filters: {
relationsToMe: {
source_id: { $eq: taskId }
}
}
}
Filterable Task Fields
These fields are defined in the backend Task model's $filterFields:
| Field | Type | Description |
|---|---|---|
id | uuid | Task UUID |
title | string | Task title |
description | string | Task description |
number | integer | Task number |
status | enum | 'open', 'closed', 'canceled', 'processing', 'executed', 'failed', 'inactive' |
priority | enum | 'low', 'normal', 'high', 'urgent' |
planned_start | datetime | Planned start date |
planned_end | datetime | Planned end date |
due | datetime | Due date |
license | uuid | License UUID |
parent | uuid | Parent task UUID |
project | uuid | Project UUID |
type | relation | Task type relation |
sbscode | string | SBS code reference |
assigned_to | uuid | Assigned user UUID |
taskProject | relation | Project relation |
is_private | boolean | Private task flag |
is_task | boolean | Is task (vs workflow) |
is_locked | boolean | Locked task flag |
relations | relation | Outgoing task relations |
relationsToMe | relation | Incoming task relations |
Special Filter Fields
| Field | Type | Description |
|---|---|---|
label_ids | array | Array of label UUIDs to filter by |
label_operator | string | 'and' (all labels) or 'or' (any label) - default: 'and' |
Note: Label filters are processed separately from Purity filters.
Selectable Fields
Fields that can be requested via fields parameter:
id, title, description, number, status, priority, planned_start, planned_end,
due, project, license, parent, sbscode, assigned_to, created_by, position_x,
position_y, is_private, is_task, is_locked, script, trigger_id
Include (Relations)
Load related resources. Defined in TaskQueryBuilder::getRelations():
{
include: 'taskProject,labels,children,assignedTo,trigger,trigger.event'
}
Available Relations
| Relation | Model | Description |
|---|---|---|
taskProject | Project | Associated project |
parentTask | Task | Parent task |
children | Task | Child tasks |
activities | TaskActivity | Activity log entries |
createdBy | User | User who created the task |
assignedTo | User | Assigned user |
activities.activityUser | User | Activity log with users |
labels | LabelOs | Task labels (V2) |
attachments | TaskFile | File attachments |
checks | TaskCheck | Checklist items |
type | TaskType | Task type |
sbsObject | SbsRecord | SBS code record |
relations | TaskRelation | Outgoing relations |
relations.queries | TableQuery | Relation queries |
relationsToMe | TaskRelation | Incoming relations |
rules | TaskRule | Task rules |
rules.resource | User/Role | Rule resources |
app | ResourceApp | Associated app |
trigger | Trigger | Associated trigger |
trigger.event | Event | Trigger's event |
assignableUsers | (computed) | Users that can be assigned |
Note: assignableUsers is a computed property, not a true relation. It's handled specially.
Pagination
{
per_page: 200, // items per page (default: 10)
page: 1 // page number (1-indexed)
}
Without pagination params, all results are returned via ->get().
With pagination params, results use ->simplePaginate().
Sorting
Uses Spatie QueryBuilder sorting:
{
sort: ['created_at'] // ascending
sort: ['-created_at'] // descending (prefix with -)
sort: ['priority', '-due'] // multiple fields
}
Complete Example
import qs from 'qs'
const query = qs.stringify({
filters: {
status: { $in: ['open', 'processing'] },
priority: { $notIn: ['low'] },
project: { $eq: projectId },
title: { $contains: 'bug' },
$or: [
{ assigned_to: { $eq: null } },
{ assigned_to: { $eq: currentUserId } }
],
label_ids: ['label-uuid-1', 'label-uuid-2'],
label_operator: 'or'
},
fields: {
'tasks': 'id,title,status,priority,due',
'taskProject': 'id,name',
'labels': 'id,name,color'
},
include: 'taskProject,labels,assignedTo',
per_page: 50,
page: 1,
sort: ['-priority', 'due']
})
const tasks = await taskService.getV2Tasks(licenseId, query)
URL Encoding
Use qs.stringify() for proper encoding:
import qs from 'qs'
// Basic usage
const query = qs.stringify(params)
// With options for complex queries
const query = qs.stringify(params, {
encodeValuesOnly: true, // don't encode bracket keys
arrayFormat: 'brackets' // arrays as foo[]=a&foo[]=b
})
Response Format
Paginated Response
{
data: Task[],
links: {
first: string | null,
last: string | null,
prev: string | null,
next: string | null
},
meta: {
current_page: number,
from: number,
path: string,
per_page: number,
to: number
}
}
Non-Paginated Response
{
data: Task[]
}
App Manifest (app-config.json)
Every ANT app can ship an app-config.json in its distribution bundle. The OS and App Store read this file to discover what the app declares about itself: its version, tagline, tables, signal topics, and — for the AI overlay — its capabilities.
Schema validation
A JSON Schema is published with this package at schemas/app-config.schema.json. Point the $schema key at it for editor autocompletion, hover docs, and validation:
{
"$schema": "node_modules/@antcde/connect-ts/schemas/app-config.schema.json",
"version": "1.0.0",
"tagline": "My App",
"tables": [],
"signals": {
"topics": {}
},
"capabilities": {
"queryParams": {},
"intents": []
}
}
TypeScript types for the whole manifest are exported from the package: AppManifest, AppManifestCapabilities, AppManifestQueryParam, AppManifestIntent, AppManifestTable, AppManifestTopic.
Sending the manifest to the OS
Apps can send their manifest to the OS during the comms handshake. This makes tables and topics visible in the OS context menu without an extra API call, and works across CORS boundaries (e.g. remote OS + local dev server).
import { useCommsClient } from '@antcde/vue-utils'
import manifest from '../app-config.json'
const comms = useCommsClient(connect, undefined, manifest)
The OS receives the manifest via the handshake ACK and exposes it on the comms host. Any app that passes a manifest will appear in the OS context menu with its declared tables and topics.
Tables
Tables are created when the app is installed. Each entry declares a table name, where it lives (context), and its columns:
{
"tables": [
{
"name": "MY_APP_ITEMS",
"context": "project",
"master_table": true,
"columns": [
{ "name": "title", "type": "text", "required": true },
{ "name": "status", "type": "dropdown", "options_value": ["draft", "active"] }
]
}
]
}
| Field | Required | Description |
|---|---|---|
name | yes | Table name, unique within the app |
context | no | "project" (default) creates the table in the project on install; "license" creates it at the license level |
master_table | no | Marks the app's primary table (default false) |
columns | yes | Column definitions (at least one) |
Column types: integer, text, text-field, boolean, date, link, email, dropdown, document, float, sbscode, table. Column fields include required, is_unique, is_indexed (max 2 per table), default_value, options_value (required for dropdown), hint, and field_name. A document column can add dms_folder, a folder path under the table owner's DMS root ("Inspections/Photos"): missing folders are created on install, uploads land there, and users can't change it while the app declares it.
Topic declarations
Topics enable generic pub/sub between apps via Echo whisper channels. Each key under signals.topics is a topic name (must match [A-Za-z0-9_-]):
{
"signals": {
"topics": {
"cursor-positions": {
"scope": "project",
"description": "Live cursor positions shared between editors",
"direction": ["send", "receive"],
"schema": { "type": "object" }
}
}
}
}
| Field | Required | Description |
|---|---|---|
scope | no | "project" (default) or "license" |
description | no | Human-readable summary shown in the App Store |
direction | no | Array of "send" and/or "receive". Defaults to ["receive"] |
schema | no | JSON Schema describing the payload shape |
Sending and receiving topics
// publish
signal.send({ topic: { name: 'cursor-positions', data: { x: 10, y: 20 } } })
// subscribe
signal.receive((s) => {
if (s.topic?.name === 'cursor-positions') {
handleCursor(s.topic.data)
}
})
The OS handles fan-out: when an app publishes a topic signal, the OS redistributes it to all other loaded apps and whispers it on the scoped Echo channel so apps in other browser tabs receive it too.
Capabilities
capabilities declares what the app can be driven to do through its URL — the query params it reads and the named actions (intents) it exposes. The AI overlay reads this to turn a natural-language request ("open the app on the sockets tab") into a concrete launch with the right query string. It is metadata about the app's URL surface — it does not change how the app renders.
{
"capabilities": {
"queryParams": {
"tab": {
"type": "string",
"description": "Which tab to open on load.",
"enum": ["overview", "items", "settings"]
},
"status": {
"type": "string",
"description": "Filter the list to this status.",
"enum": ["draft", "active", "archived"]
}
},
"intents": [
{ "id": "open-tab", "description": "Open the app on a specific tab", "params": ["tab"] },
{ "id": "filter-items", "description": "Open the app filtered to a status", "params": ["tab", "status"] }
]
}
}
queryParams — a map keyed by param name. Each entry describes one query param the app reads off its URL.
| Field | Required | Description |
|---|---|---|
type | yes | Value type — "string", "number", "boolean", "date", "time" or "datetime". On the URL everything is a string; the non-string types are rendering + validation hints |
description | no | What the param does. Surfaced to the AI as tool-parameter help — write it for a model to read |
enum | no | Allowed values. Becomes a hard constraint, so the AI can only pick a listed value |
multiple | no | Accept several values (multi-select, CSV on the wire) (default false) |
required | no | Whether the param must be supplied to invoke the app (default false) |
access | no | "readwrite" (default) or "write" — commandable. "read" only reflects app state on the URL: the AI sees the current value and the confirm form shows it disabled, but neither may set it |
intents — named actions that group queryParams into a recognizable target. An intent adds no behavior; it points at params that already exist, giving the AI a labelled action to match a request against.
| Field | Required | Description |
|---|---|---|
id | yes | Stable intent identifier |
description | yes | What the intent does. Read by the AI to match a user request |
params | no | Names of queryParams this intent uses (each must exist in the map) |
The overlay builds one tool per available app from the live app list: each queryParams entry becomes a tool parameter (enum → constrained choice, required → non-optional, description → parameter help), plus a built-in pane selector. access: "read" params are excluded from the tool schema. How reliably the model fills the right param is a direct function of good descriptions and tight enums — treat them as prompt engineering. Your app must then read these params off its own URL (window.location.search) and act on them.
For the app currently open, the overlay also reads the declared params back off the URL and feeds them to the model as current state, and pre-fills the confirm form from them (URL loses to session memory, which loses to the prompt). That only works if your app keeps the params in sync as the user navigates — writing them on load is not enough. Declare a param read when the app maintains it as state the AI should know about but must not command.
Resource Signals
Arbitrary resource signals let apps send and receive domain-specific events (e.g., cat, homeowner, invoice) without modifying the core ChangeSignal types. Signals are distributed locally across split-screen apps and can optionally be backed by WebSocket (Echo) for backend-driven events.
Recipe 1: Local — send & receive across all apps
All split-screen apps receive the signal via the OS redistribution watcher. No backend needed.
Send
const { signal } = comms
signal.send({
resource: {
cat: {
id: crypto.randomUUID().slice(0, 8),
action: 'created',
data: { sound: 'meow', volume: 'loud' },
},
},
})
Receive (raw — no Echo subscription)
signal.receive((s) => {
const cat = s.resource?.cat
if (cat) {
console.log(cat.id, cat.action, cat.data)
}
})
Receive (with — also sets up Echo subscription)
// Also sends an `observe` signal → host subscribes to Echo channel
signal.with('cat').receive((cause) => {
console.log(cause.id, cause.action, cause.data)
})
signal.send()is local only — it never touches the backend/socket. The OS redistribution watcher forwards it to all other split-screen apps.
signal.receive()catches all signals (local + Echo-backed).signal.with('cat').receive()filters forcatAND subscribes to the Echo channel for backend events.
Recipe 2: Over Socket (Echo-backed) — backend broadcasts to all clients
Only types a backend module broadcasts arrive over the socket. For example, DHME's DhmeResourceChanged announces production-location and similar types on App.License.{id}.Resources. The backend has no generic event that broadcasts an app-defined type such as cat, so for those use Recipe 1 (local) or a topic. The examples below use cat only as a placeholder type name.
Receive (project-scoped)
const { signal, context } = comms
watch(context, ({ project }, _, onCleanup) => {
if (project) {
onCleanup(
signal.with('cat', undefined, undefined, ['project']).receive((cause) => {
// cause.meta?.source === 'timeseries' (from Echo)
console.log(cause.id, cause.action, cause.data)
})
)
}
})
What happens under the hood
signal.with('cat', ..., ['project'])sends anobservesignal to the host- Host calls
newSignalEchoAdapter→ subscribes toApp.Project.{projectId}.Resources - Backend broadcasts
resource.cat.created/resource.cat.updatedon that channel - Echo adapter receives it → sends signal back to the iframe client
cause.meta.sourceis'timeseries'
Backend side
A backend module broadcasts an event on App.{Project|License}.{id}.Resources with broadcastAs() returning resource.<type>.<action> and a payload carrying the id and action (see app/Modules/DHME/Realtime/DhmeResourceChanged.php in ant-core). There is no shared event class for this, so adding a new broadcast type is a backend change.
Summary
| Method | Distribution | Backend needed? | Use case |
|---|---|---|---|
signal.send() + signal.receive() | Local (all split-screen apps) | No | App-to-app communication |
signal.with('cat').receive() | Echo-backed | Yes — a backend module must broadcast this type | Backend-driven (default scope) |
signal.with('cat', ..., ['project']).receive() | Echo-backed (project-scoped) | Yes — a backend module must broadcast this type | Backend-driven real-time updates |