NAL — Nocarta Automation Language

NAL is Ruby. Every line of a script is either an ordinary Ruby statement or one of the commands listed on this page — the ones that open a page, fill a field, read a table, call an API or drive a Windows window. Anything else is refused before the run starts, with the line number and the form that exists.

That is the whole language, and it is worth saying precisely, because it is a promise rather than a family resemblance:

  • What NAL accepts behaves exactly as Ruby does. 7 / 2 is 3. -7 % 2 is 1. "abcdef".slice(1) is "b". If you know Ruby, nothing here will surprise you; if you do not, anything you read about Ruby is true here.
  • What NAL does not have, it refuses out loud. There are no classes, no begin/rescue, no lambda, no libraries to require. You get an error naming the thing that exists instead, never a silent difference.
  • One deliberate exception, written out under “Maps” below: :name and "name" are the same key, because a separate Symbol type is the first thing that trips up someone who has never programmed.

Four things shaped it, in this order:

  1. Anyone who uses a browser and a spreadsheet can learn it. A script reads like plain instructions, with ordinary programming when you need it: variables, conditions, loops, functions. A mistake is reported with its line number before anything runs.
  2. The same words everywhere. Whether an automation drives a website, runs in your own browser tab, or drives a Windows application, the language is identical. Only the commands that talk to that kind of target change.
  3. An assistant can write it for you. Claude, ChatGPT or any other MCP client reads this same page, and can create, update and check your automations through Nocarta’s API. If you are that assistant: write Ruby.
  4. It is Ruby, so the names, the punctuation and the behaviour are all the ones you already know.

If you are looking for how to create an automation, bind a credential or dispatch a run, see Automation. This page covers only the language itself.

The three kinds of automation

All three are written in NAL. Every automation picks one of these when you create it. The kind decides where the script runs and which commands it may use; the language is the same in each.

Kind Runs on NAL, plus
Desktop The Nocarta desktop app, driving its own browser The page commands, js / pw
Browser Your own browser tab, no desktop app needed Nothing extra: the core language, http and api
Windows The Nocarta desktop app, driving a Windows application The Windows commands

The core language below is NAL itself and works in all three, and so do http requests and api calls. Everything else is listed under the kind it belongs to. If you use a command your kind does not have, the editor tells you when you save — it names the command and the kind that does have it, so nothing surprising happens mid-run.


Core language (every kind of automation)

Comments

A line that starts with # is a comment. After a command, # starts a comment when it has a space before and after it. # is the only comment marker.

# find the invoice table first
click ".invoices"   # then open it

Variables

name = "Maria Silva"
count = 0
count += 1                 # also -= *= /=
rows[0] = "first"
totals["march"] = 1200

Some words belong to the language and cannot be used as names: every statement word (log, ask, validate, output, stuck, finish, if, for, def, …) and the first word of every command (click, fill, type, read, list, table, page, launch, …), in any capitalisation. Writing type = 1 or for click in rows stops with an error that names the word and suggests another name, such as type_value. The words are still fine after a dot or as a key: row.type and {page: 2} work.

Put a value inside text with #{...}, in double quotes:

log "found #{rows.length} rows for #{name}"

Single quotes keep the text exactly as written: '#{name}' is those seven characters, and so is '{{PORTAL.PASSWORD}}' — nothing inside single quotes is ever replaced. To keep a literal #{ or {{ inside double quotes, write \#{ or \{{.

The last value produced — by a command, an expression or an assignment — is always _, an ordinary variable:

read table "#grid"
log "rows: #{_.length}"
rows = _

Values

Text, whole numbers, decimals, true, false, nil, lists and maps.

items  = ["a", "b", "c"]
person = {name: "Maria", age: 41}
range  = 1..10
exact  = 1...10               # 1 to 9 — the end is left out

false and nil are false; everything else is true, including 0 and "". A number and a piece of text never mix silently: "5" + 1 is an error, "5".to_i + 1 is 6.

Whole numbers and decimals are different kinds of number, as they are in Ruby. 7 / 2 is 3, because dividing two whole numbers gives a whole number; 7 / 2.0 and 7.fdiv(2) are 3.5. A decimal writes itself with its point, so 5.0 reads as "5.0" — call .to_i or .round before putting one in a form field.

Maps: {name: "Maria"}, {"name" => "Maria"} and {"name": "Maria"} all build the same thing, and person[:name], person["name"] and person.name all read it. This is the one place NAL parts from Ruby on purpose: a map key is always text, so :name and "name" are the same key. (In Ruby they are not.) Two consequences worth knowing: a key that is also a method name, like length, is reachable as person["length"] rather than person.length; and keys that read as whole numbers come back before the others when you list them.

Conditions

if count > 10
  log "plenty"
elsif count > 0
  log "a few"
else
  log "none"
end

unless paid
  log "open invoice"
end

Compare with == != < <= > >= <=>. Combine with &&, ||, ! or their word forms and, or, not — the words bind more loosely, exactly as in Ruby:

if total > 0 && !paid
  log "open invoice"
end

x || "N/A" gives x, or "N/A" when x is nil or false.

A condition can sit at the end of the line it governs, and if itself has a value:

log "open invoice" if total > 0

status = if paid
  "closed"
else
  "open"
end

case picks a branch by value, by range, or by pattern:

case amount
when 0
  log "nothing to pay"
when 1..99
  log "small"
when /^EUR/
  log "already in euros"
else
  log "large"
end

Loops

rows.each do |row|
  break if row == "stop"
  log row
end

rows.each_with_index do |row, i|     # with the position
  log "#{i}: #{row}"
end

5.times { |n| log n }

for n in 1..5
  log n
end

while count < 10
  count += 1
end

until done
  done = check_something
end

break leaves the loop, next skips to the following item; both take a value (break row) which becomes the loop’s answer.

A do |row| … end block is how Ruby loops, and it is what an assistant will write. for exists too and reads more plainly; the only difference is that for leaves its variable behind after the loop, as Ruby’s does.

Blocks are only ever attached to a method call. You cannot store one in a variable, so lambda, -> and yield are not part of NAL.

Functions

def full_name(first, last)
  "#{first} #{last}"
end

log full_name("Maria", "Silva")

A function gives back the value of its last line; return leaves early. A function cannot take a block — pass it the values it needs.

Saying things

Two separate channels, and the difference matters:

log "starting the invoice run"        # for a person — chat panel and run log
output invoices: rows, total: amount  # for the next step — machine-readable

output takes key: value pairs, keys in lower_snake_case. Use it more than once and the pairs merge; the last value written for a key wins. To hand instances to the next step of a recipe, emit them as instance_ids.

ask puts a question to the assistant and gives you back its answer:

ask "which of these looks like the invoice total? #{cells}"
answer = _

Add , model: "…" to say which model should answer — the name of one of your own models, or of one of ours.

Deciding

ask gives you words back. decide gives you an answer with a number attached, from a model built for judging rather than for writing:

d = decide "Is this a refund request?" about notes
if d.answer
  log "refund, #{(d.probability * 100).round}% sure"
end

team = decide "Which team should handle this?" about notes among ["billing", "support"]
level = decide "How urgent is it?" about notes levels ["calm", "normal", "urgent"]

Write the question, then about what it is about — text, a map or a list. With nothing else, the answer is yes or no: d.answer is true or false, and d.probability is how sure the model is. among gives it a list of names to pick from; levels gives it an ordered scale to place things on. Those two answer with d.answer and d.confidence.

Two things can follow, after a comma:

d = decide "Is this a refund?" about notes, threshold: 0.7, model: "Airwalk"

threshold: is where yes begins — 0.5 unless you say otherwise, so a probability of 0.6 is a yes by default and a no at 0.7. model: names which of your models answers.

Every answer also carries d.answered_by, which tells you which model actually answered — so a script can check it was the one you asked for.

A decision model is one of your own, set up in Settings → Preferences → LLM Configuration. It always runs on your own machine through Nocarta Desktop: in a browser automation the question is sent to your desktop and the answer comes back, so your desktop has to be running.

Asking the person watching

code = im_stuck "Type the code from your phone", expect_input: true
ok   = im_stuck "Continue with the import?"

im_stuck pauses the run and waits for the person. With expect_input: true you get what they typed; without it, true when they confirm. Cancelling stops the run, unless you add fail_on_cancel: false, which gives false instead. It works where a person is watching: the assistant chat, the debugger, or a recipe step someone started by hand. A batch or an unattended run stops instead.

Checking as you go

validate rows.length > 0

If the check comes out false the run stops there and says so. It never quietly carries on — which is the whole point of writing the check.

Finishing, and getting stuck

checkpoint "logged-in"                 # a named point you can resume from
finish "nothing to invoice today"      # end the run early, successfully
stuck "the portal is down for maintenance", category: "external_outage"
on fail message "Could not reach the invoice list"
on stuck restart from checkpoint max 2 with fresh browser
  • checkpoint goes at the top level of the script, or inside an if. Not inside a loop or a function, and each name is used once.
  • finish ends the run as a success. stuck ends it as needing help, and the message is what the person reading the run will see.
  • on stuck restart is written once, at the top level. max is 1 to 5, and you restart from start, from checkpoint or from command. with fresh browser combines with the first two, in Desktop automations.

Working with values

Methods chain: rows.first.upcase. To work on what a command gives back, capture it first:

cells = read row 0
log cells.join(", ")

The methods are Ruby’s, with Ruby’s names and Ruby’s behaviour. Every Ruby method on text, whole numbers, decimals, lists, maps and ranges that does not take a block is here, and so are the block ones listed below. Anything else is refused by name.

Text — length size empty? upcase downcase capitalize swapcase strip lstrip rstrip chomp chop reverse chars lines to_s to_i to_f split slice sub gsub scan match match? index rindex include? start_with? end_with? ljust rjust center tr delete squeeze count partition rpartition succ ord % each_char each_line

Lists — length size empty? first last reverse flatten compact uniq sort slice include? index rindex concat join push pop shift unshift insert delete delete_at min max sum take drop rotate zip product transpose tally fetch dig values_at to_a to_h count any? all? none? — and with a block: each each_with_index each_with_object map select reject find find_index filter_map flat_map reduce sort_by min_by max_by group_by partition take_while drop_while each_slice each_cons uniq sum count any? all? none?

Maps — keys values length size empty? include? key? has_key? value? key fetch dig merge delete invert to_a to_h values_at — and with a block: each map select reject find transform_values transform_keys sort_by sum count any? all?

Numbers — to_s to_i to_f abs ceil floor round truncate fdiv divmod even? odd? zero? positive? negative? succ pred gcd lcm digits clamp between? chr — and with a block: times upto downto step

Ranges — to_a size count first last min max sum include? cover? exclude_end? — and every list method that takes a block.

The behaviour is Ruby’s, which means:

  • gsub("a", "b") changes every a; sub changes only the first. Both take a pattern too: gsub(/\d+/, "#").
  • split with nothing splits on spaces and drops empty ends: "a b c".split gives ["a", "b", "c"]. split(" ", 2) gives at most two parts: "ROSSI MARIO LUIGI".split(" ", 2) is ["ROSSI", "MARIO LUIGI"].
  • slice(1) gives ONE item; slice(1, 3) gives three from position one; slice(1..3) does the same. "abcdef"[1, 3] is another way to write it.
  • sort needs a list it can order — one that mixes numbers and text is an error, because neither order is right.
  • compact removes nil only. "" and 0 are values and stay.
  • join with nothing puts the items straight together; join(", ") separates.
  • concat, push, pop and their kind change the list in place, as Ruby’s do. + gives a new one.
  • to_i on text that is not a number is an error, not 0. This is the one place NAL refuses something Ruby answers, and it is deliberate: a field reading “N/A” must never quietly count as zero in a total.
  • Two digits is rjust(2, "0"): "7".rjust(2, "0") is "07".

Regular expressions are Ruby’s, written between slashes:

if code.match?(/^INV-\d{4}$/)
  number = code.match(/\d+/)[0]
end
clean = name.gsub(/[^a-zA-Z0-9 ]/, "")

i ignores case, m lets . match a line break, x lets you space the pattern out.

Built-in functions

today()                       # "2026-09-15"
today("dd/MM/yyyy")
now()                         # "2026-09-15 14:30:00"
weekday(2026, 9, 15)          # "TUE" — add a language: weekday(2026, 9, 15, "it")
days_in(2026, 9)              # 30
format_date(2026, 9, 15, "EEE dd MMM")
url_encode("Ada & co")        # "Ada%20%26%20co" — safe inside a URL
exists(".invoice-table")      # true when something on the page matches

Date tokens: yyyy MM dd HH hh mm ss tt EEE.

exists needs a page to look at, so it is available to Desktop automations.

The data a run is given

A run over a form instance sees that instance as instance; a step in a recipe sees what the previous step emitted as input. Both are ordinary maps:

fill "#name" with "#{instance.fields.full_name}"
fill "#code" with instance.fields["code_" + day]
open "https://portal.example.com/invoice/#{input.invoice_id}"

Reading a field the run was not given is an error, never a blank — a blank would let a form be submitted empty while the run reports success. To ask whether it is there, use || or compare with nil:

phone = instance.fields.phone || "N/A"

Every field of the template is there, filled or not. A field nobody wrote anything in arrives as "", so you can read it, compare it and fill with it like any other. What stops the run is a name that is not on the template — instance.fields.notse for a field called notes is an error naming it, which is how a typo is caught instead of quietly filling a form with nothing.

instance.fields walks as [name, value] pairs — instance.fields.each do |name, value| — and so do instance.metadata, instance.instance_id and instance.template_id are also there.

Calling other people’s APIs

http makes a request, in every kind of automation:

r = http get "https://api.example.com/items/#{id}"

r = http post "https://api.example.com/cards?list=#{url_encode(list_id)}", {
  headers: {"X-Request-Source": "nocarta", Authorization: "Bearer {{TRELLO.VALUE}}"},
  body:    {name: title, desc: "Card for #{customer}"}
}

r = http put "https://api.example.com/items/#{id}", {body: {name: title}}
r = http patch "https://api.example.com/items/#{id}", {headers: {"If-Match": etag}, body: {name: title}}
r = http delete "https://api.example.com/items/#{id}"

A request is always one line: the method, the address, and — when you need to say more — one {…} after a comma holding headers: and body:. The {…} may be spread over several lines, as here. There is nothing else to learn: a token is an Authorization header like any other, and a query string is part of the address, written there with url_encode(value) around anything that could contain a space or an &.

Methods: get post put patch delete.

You get back {status, status_text, headers, body, json}. A 404 or a 500 is a result you inspect, not a failure — only a request that could not be made at all stops the run:

r = http get "https://api.example.com/invoices"
if r.status == 200
  log "got #{r.json.length} invoices"
else
  stuck "The invoice API answered #{r.status}"
end

The body’s kind decides how it is sent: a map or a list goes as JSON, text goes exactly as you typed it. Write body: {name: title}, never body: "{\"name\": \"#{title}\"}" — the map is turned into correct JSON for you, whatever the values contain. A get request has no body.

Requests go only to the hosts listed on the automation’s Settings tab. An empty list reaches nothing.

Your Nocarta data

api reads and writes your own Nocarta data from inside a script, in every kind of automation. You give it a path, not a full address — the script works the same wherever it runs, and you never paste a token into it:

instances = api get "/form_instances?form_template_id=#{template_id}"

result = api post "/form_instances/bulk_create", {
  body: {form_template_id: template_id, instances: rows}
}

api put "/form_instances/#{id}", {body: {name: "Checked"}}
api patch "/form_instances/#{id}", {body: {name: "Checked"}}
api delete "/form_instances/#{id}"

Same request hash and same result shape as http. The script runs as you, with your own permissions. api get "/documentation" lists everything it can reach.

Credentials

Never type a password into a script. Bind the credential to the automation and refer to it by name, in capitals:

fill "#password" with "{{PORTAL.PASSWORD}}"

{{...}} in capitals is the only use of double braces: it means a credential. The placeholder is what is stored, and what appears in logs. The real value is put in at the last moment, on the machine running the automation. The script never contains it and never prints it. See Secrets.

A placeholder is filled in only where you wrote it inside double quotes (or in js/pw code). Inside single quotes it is plain text, like everything else there: '{{PORTAL.PASSWORD}}' reaches the page as those braces. Text that comes from a page or from an instance field is never filled in either, even if it happens to look like a placeholder.


Desktop automations: page commands

These drive the browser inside the Nocarta desktop app.

A command can stand alone, or be the whole right-hand side of an assignment:

open "https://example.com/invoices"
rows = read table

Moving around

open "https://example.com"
back
forward
refresh
screenshot
close browser
browser status

Doing things on the page

click "#submit"                          # a CSS selector
click text "Log in"                      # whatever carries that text
click "#row-9" holding "Shift"           # click with keys held down
fill "#email" with "[email protected]"
fill "Maria Silva" into "the customer name box"   # described in words
type "some text"                         # keystrokes, wherever the focus is
select "Italy" from "#country"
press "Enter"                            # a key name
press "Control+A" in "#notes"
press "Cancel"                           # not a key name: clicks that button
search and click "the blue download button"

fill … with names the field with a selector; fill … into describes it in words and lets the assistant find it. type sends keystrokes to whatever already has the focus — to put a value in a named field, use fill.

Key names are the usual ones: Tab Enter Escape Backspace, F1–F24, the arrows, Home End PageUp PageDown, and chords like Control+A.

click … holding holds keys down for the length of the click — Shift to extend a selection to the row you click, Control to add one row without losing the rest. Name several with +, the same way a chord is written:

click "#row-3"
click "#row-9" holding "Shift"           # rows 3 to 9
click "#row-12" holding "Control+Shift"

You can hold Shift Control Alt Meta, or ControlOrMeta for the one that means “the usual shortcut key” on whichever system the automation runs on. Holding keys works with a selector only — click text "…" finds its element by reading the page, and that kind of click cannot carry held keys.

Looking at the page

links   = list links
links   = list links "invoice"          # only those matching
matches = find by text "Total"
box     = find input near "Customer name"
thing   = find "the download button"     # described in words
items   = find on selected
text    = read focus                     # what is in the focused field
layout  = show elements
sel     = get selector

find by text, find … near and find give back nil when nothing matches, so you can test them:

button = find "the export button"
if button
  click button
end

find input near "…" accepts input button select textarea link a checkbox radio field element.

Tables

rows  = read table
rows  = read table ".invoices"
row   = read row 2
cell  = read table cell row 2 col 3
col   = find column "Amount" in row 0
fill table row 2 col 3 with "180,00"
switch to table ".invoices"
exit table

Frames, tabs and windows

list iframes
list frames
switch to iframe "#payment"
switch to frame "content"
exit iframe
list pages
switch to page "last"
switch to page "invoices"
switch to page 2

Handy

sleep 2                       # seconds
highlight ".total" red        # red blue green black white orange
clear highlights

When the commands are not enough

js document.querySelectorAll(".row").length

js
  const rows = document.querySelectorAll(".row")
  return rows.length
end

js with customer, year
  return findInvoice(customer, year)
end

pw
  await page.getByRole("button", {name: "Export"}).click()
end

js runs JavaScript in the page, pw runs a Playwright instruction. The code inside is passed through exactly as written — nothing is substituted in it, so #{...} does not work there, and # is not a comment there either. To get a script value in, list it after with and use it as an ordinary variable. A credential placeholder {{ALIAS.FIELD}} is the one thing that does get filled in.


Browser automations

These run inside your own browser tab, and need no desktop app — the only kind that works on a phone. They use the core language, http and api, exactly as described above. There are no page commands (there is no automated browser), no js / pw, and no exists.

r = http get "https://api.example.com/invoices"
validate r.status == 200

api post "/form_instances/bulk_create", {
  body: {form_template_id: template_id, instances: r.json.items}
}

Windows automations

These drive a native Windows application through the desktop app. The control names and key notation are those of the Windows UI Automation library the desktop app uses.

Every command has one written form, and the editor checks it when you save: a missing word is an error that shows the form. A control is written as its kind, bare, then its name in quotes, then the window it lives in: button "Save" in "Invoices". Kinds: button, edit, checkbox, combobox, menu, menu item, tree, tree item, datagrid, tab, link, radio.

Commands that give you something back. Each names the window it reads from:

title  = read title of "Invoices"
text   = read text from "Invoices"
value  = read value of edit "Name" in "Invoices"
grid   = read grid "Invoices"
grid   = read grid datagrid in "Invoices"
cell   = read grid cell row 0 col 1 "Invoices"
fields = read fields in "Invoices"
ctrls  = read controls button in "Invoices"
tree   = read tree of "Invoices"
dialog = read dialog
list windows

read controls lists every control of one kind in a window, and read tree gives you the whole window as the application lays it out: both are how you find out what things are called before you write the commands that act on them. read grid cell counts rows and columns from 0.

What you get back: read title, read text, read value of and read grid cell give text. read grid and read tree give a map you look inside (grid.headers, grid.cells[0][1], tree.tree); read dialog gives a map when a dialog is open and false when none is; read fields, read controls and list windows give a list.

Commands that act:

launch "C:\\Program Files\\App\\app.exe"
launch "notepad.exe" "C:\\notes.txt"
focus window "Invoices"
click button "Save" in "Invoices"
set value of edit "Name" in "Invoices" to "Maria Silva"
type "some text" into "Invoices"
press "Enter" in "Invoices"
select combobox "Country" in "Invoices"
toggle checkbox "Active" in "Invoices"
expand tree item "Edit" in "Invoices"
collapse tree item "Edit" in "Invoices"
invoke button "Refresh" in "Invoices"
focus edit "Name" in "Invoices"
click dialog button "OK"
set clipboard to "text"
wait 500
sleep 2
close window "Invoices"
close app "app.exe"

wait and sleep are not the same unit. wait belongs to the Windows vocabulary and counts milliseconds, like the rest of it — wait 500 is half a second. sleep is the core command and counts seconds everywhere in the language, here included. Writing wait 2 pauses for two milliseconds, which looks like no pause at all.

These are all the Windows commands there are. Anything else is not a command: the editor refuses it when you save, naming the line.


When something goes wrong

A command that finds nothing fails the step — click "#missing" stops the run rather than passing silently. That is deliberate: a script that quietly skips its own work is worse than one that stops. When an element really is optional, say so:

if exists(".cookie-banner")
  click text "Accept"
end

Ruby that NAL does not have

NAL is a subset of Ruby. These are refused by name, with the thing that exists in their place — never silently.

Ruby Why not, and what to write
begin / rescue / ensure / raise / retry A run does not catch its own failures. Stop with stuck "…", assert with validate, set the message a person sees with on fail message, and retry with on stuck restart.
class, module, attr_accessor, self A script is statements and defs.
lambda, ->, proc, yield, &blk A block is only ever attached to a method call; it is never a value. Name reusable code with def.
require There are no libraries to load.
rand, sample, shuffle A run has to be repeatable.
freeze, dup, clone, object_id, class, is_a? There is no object identity to work with.
Date, Time, File, IO, gets Use today(), now(), format_date(), and im_stuck to ask a person.
[1, 2].map with no block, each_slice(2).to_a There is no Enumerator: every one of these methods needs its block.
"abc".to_i, nil.to_i Ruby answers 0. NAL stops instead, so a field reading “N/A” never counts as zero.
"a" << "b" Text never changes in place. Write a += "b".
2 ** -1 That is a fraction in Ruby, and NAL has no Rational. Write 1.0 / (2 ** 1).
x = a and b Ruby reads this as (x = a) and b, which is almost never what someone means. Write x = a && b.

Older NAL spellings

The language used to have its own names for some of these. Each is refused with a message naming the Ruby form.

Instead of Write
set x = 5, preset, runtime, init x = 5
// a comment # a comment
each x in items, repeat 5 for x in items, 5.times do |n|
x ?? "N/A" x || "N/A"
else if, elseif elsif
"{{name}}", "{{instance.fields.x}}" "#{name}", "#{instance.fields.x}" — double braces are for credentials only
cells.includes("a") cells.include?("a")
s.starts_with("a"), s.ends_with("z") s.start_with?("a"), s.end_with?("z")
s.replace(a, b), s.replace_first(a, b) s.gsub(a, b), s.sub(a, b)
s.pad_left(3, "0"), s.pad_right(3), s.pad_zero s.rjust(3, "0"), s.ljust(3), s.rjust(2, "0")
s.remove_special s.gsub(/[^a-zA-Z0-9 ]/, "")
push items, "a" items.push("a")
name result rows rows = read table
echo "hi", report "hi" log "hi" (or Ruby’s puts)
click if present "#x" if exists("#x") … end
type "Maria" into "#name" fill "#name" with "Maria"
read active, click by text "Go" read focus, click text "Go"
output rows output rows: rows
press Enter press "Enter" — a bare word is a variable

Examples

Walk a site and report what is there

open "https://example.com"
links = list links
log "found #{links.length} links"
screenshot
output link_count: links.length

Fill a form and check it went through

open "https://example.com/contact"
fill "#name" with "Maria Silva"
fill "#email" with "[email protected]"
click text "Send"

if exists(".confirmation")
  log "submitted"
else
  stuck "No confirmation appeared after sending the form"
end

Log in, then read a table

on stuck restart from checkpoint max 2 with fresh browser

open "https://portal.example.com/login"
fill "#user" with "{{PORTAL.USERNAME}}"
fill "#password" with "{{PORTAL.PASSWORD}}"
click text "Sign in"
validate exists(".dashboard")
checkpoint "logged-in"

open "https://portal.example.com/invoices"
rows = read table
rows.each do |row|
  log row.join(" | ")
end
output invoices: rows

Fill a portal from an instance, one run per instance

open "https://portal.example.com/customers/new"
fill "#name" with "#{instance.fields.full_name}"
fill "#email" with instance.fields.email
fill "#phone" with instance.fields.phone || ""
click text "Save"
validate exists(".saved")

Read your own Nocarta data and post results back

template_id = "your-template-id"
existing = api get "/form_instances?form_template_id=#{template_id}"
log "#{existing.json.length} entries already"

open "https://portal.example.com/invoices"
rows = read table

api post "/form_instances/bulk_create", {
  body: {form_template_id: template_id, instances: rows}
}

finish "imported #{rows.length} invoices"

Need More Help?

Ask AI Assistant