Osy#betaRun it on Osyrin: osyrin.com

Reference / Function

[Action] — what an app offers to be done, by name

[Action("Label")] · [Action(Query)] · [Sends] · [Spends] public <ReturnType> Name(Record r, [Label("…")] [Choices(F)] <T> value, …) · Actions.Catalog() · Actions.For(record) · Actions.Find(name) · Actions.Of(F) · Actions.Choices(action, parameter) · Actions.Check(action, values) · Actions.Run(action, values, cause:) · Actions.Consent(action, values) · Actions.RunConsented(consent, values, cause:) · Actions.ValuesFromJson(action, json) · Actions.Cause · Actions.NameOf(step) · Actions.ValuesOf(step)

Mark a function `[Action]` and the app offers it to be done BY NAME — by a rule, a macro, a tool — with everything a caller needs derived from the function itself: its label, its parameters and their kinds (an enum's members are the choices), the record it acts on, what it reads and writes, and — as JSON Schemas — what it takes and what it answers. Only a marked function can be run this way, and it always runs under someone's ordinary security: the caller's, or the person whose consent it carries. What it returns comes back to whoever ran it, as JSON of its own type.

preview5 examples compiled by CIfunctionactionsautomations

Summary#

[Action] on a top-level function says "this can be done to a record, by name". Everything else is derived:

/// Mark how soon an order must go.
[Action("Set the rush")]
public string SetRush(Order o, [Label("How soon")] Rush rush) { … }

Actions.Catalog()                                   // every action the app offers, described
Actions.Run(Actions.Of(SetRush).Name, [new ActionValue { Parameter = "o", Value = id }, …])

Kits that do things on an app's behalf — the Automations kit's rules, the ReplyComposer kit's macros — are built on this one surface, so an app says once what can be done, and every one of them offers exactly that.

Signature#

[Action]                    // label derived from the name: SetRush → "Set rush"
[Action("Set the rush")]    // or said
[Action(Query)]             // it only READS — proved by the compiler; with a label: [Action("Find orders", Query)]
[Sends]  [Spends]           // on ANY function: it sends something to somebody · it spends money or an allowance
public <ReturnType> Name(Record subject,                       // the first parameter: the record it acts on (optional)
                         [Label("How soon")] Rush rush,          // a parameter's label (derived when not said)
                         [Choices(ShelfChoices)] Shelf shelf)    // a record (or a string) chosen from what F offers
public List<ActionChoice> ShelfChoices();                       // F: takes nothing, answers { Key, Label } rows

Actions.Catalog()                         → List<ActionInfo>     // Name, Label, Description, Subject, Parameters, Effects,
                                                                 //   Query, ParametersSchema, ResultSchema
Actions.For(record)                       → List<ActionInfo>     // the actions on this record's type (or a base) the caller may run
Actions.Find(string name)                 → ActionInfo?
Actions.Of(F)                             → ActionInfo           // the marked function F, checked where it is written
Actions.Choices(string action, string parameter) → List<ActionChoice>   // run as the caller
Actions.Check(string action, List<ActionValue> values) → string   // "" when they would run, else why not — nothing runs
Actions.Run(string action, List<ActionValue> values, string cause = "")  → ActionResult   // as the caller
Actions.Consent(string action, List<ActionValue> values)       → string // the caller's sealed consent
Actions.RunConsented(string consent, List<ActionValue> values, string cause = "") → ActionResult   // as its author
Actions.ValuesFromJson(string action, string json) → List<ActionValue> // one JSON object of arguments, as the values Run takes
Actions.Cause                             → string               // inside an action: the cause it was run with
Actions.NameOf(Func<T, R> step)           → string               // a step written as the action CALLED: which action
Actions.ValuesOf(Func<T, R> step)         → List<ActionValue>    // …and what it gives each other parameter, as text

public Spec Run<R>([ActionStep] Func<T, R> step)                 // a kit's parameter that takes a STEP — checked where
public Spec Run([ActionStep] Action<T> step)                     //   every caller writes one

ActionResult is { Outcome, Said, Value, Problem, RanAs } — Outcome one of Done, Invalid, Denied, Gone, Failed; Value what the function returned, as JSON (below); Said the same when it is a string — the sentence a caller shows.

Description#

What is derived, and from what#

  • The label — [Action("…")], else the function's name in words. The description — its /// comment.
  • The subject — the first parameter when it is an entity: the record the action is done TO. A caller passes its id.
  • Each other parameter — its name, [Label] (else derived), whether it is optional (a default value), and its KIND: text, number, decimal, yes/no, date, date and time, time of day, duration, an id, an enum (its members are the choices, in order, by their [Label]), a record — which must say where its choices come from with [Choices(F)], since "any row of that table" is not a choice a person can make — or a structure (a class, a Dictionary<string, T>) or a sequence (a list, an array, a set), given as JSON (below). A string may take [Choices] too.
  • Its schemas — each parameter's JSON Schema (ActionParameter.Schema), all of them as one object (ActionInfo.ParametersSchema), and what the action answers (ActionInfo.ResultSchema). They are what Json.SchemaOf<T>() — the JSON Schema of a type answers for those types, so nothing describes an action that the action could disagree with.
  • The effects — what the function reads, creates, changes and deletes, whether it reaches outside the app (External), and whether it sends something to somebody (Sends) or spends (Spends) — from the compiler's own analysis of its body and everything it calls (ActionInfo.Effects). A caller can say "this rule changes tickets and sends mail" without anyone writing it down.

A class or a list as a parameter#

An action may take a class, a list, an array, a set or a Dictionary<string, T> — the shapes a tool call or another system sends. Its value is JSON, read exactly as JsonSerializer.Deserialize<T> reads that type (so a member is named by its [ExternalName] where it has one, and an enum by its member's name), and its ActionParameter.Schema is Json.SchemaOf<T>() (with a row inside it described as its id — below). A value that is not what the schema says is Invalid, with the reason in Problem, and nothing runs.

A row is named by its id wherever it stands — as a parameter of its own, as a member of a class, as an element of a list (List<Ticket> tickets, class Move { public Ticket Ticket; public Queue To; }). The schema describes it as that id ("type": "string", "format": "uuid"), and each row is looked up, as whoever the action runs as, when the action runs. An id that names no row its caller may read is Gone — "No Ticket with id … : it does not exist, or whoever this runs as cannot read it" — and the action does not run at all. Anything that is not an id is Invalid.

What it answers#

ActionResult.Value is what the action returned, as JSON — what JsonSerializer.Serialize writes for it, with every enum by its name — so the caller holds the value itself:

var result = Actions.Run("Price", values);
if (result.Outcome == ActionOutcome.Done) {
  var quote = JsonSerializer.Deserialize<Quote>(result.Value);   // the Quote the action built
}

A number is 25, a string "It is done.", an enum "High", a list […], a class {…}, a row its id and its own values. It is "" when the action answers nothing (void) or did not run. ActionInfo.ResultSchema describes it before anything is run.

From one JSON object of arguments#

Something that holds an action's arguments as ONE JSON object — a model's tool call, written to ParametersSchema — turns it into the values a run takes with Actions.ValuesFromJson:

var result = Actions.Run(name, Actions.ValuesFromJson(name, argumentsJson));

Each member becomes its parameter's value: text for a single value ("High", 12, true, a row's id), JSON for a structure or a sequence. A null member is a value left out. It only reads the catalogue; the run is what checks the values, and refuses a member that names no parameter. The same values go to Actions.Consent, so a call a person approves is sealed exactly as it was proposed.

Checking values before they are kept#

An editor that sets up a step to run LATER — an automation's step, a macro's — asks Actions.Check before it keeps the values, and gets "" or the first problem in a person's words. It reads every value exactly as Actions.Run will — a date, a number, a duration, the JSON of a structure or a list — and says a required one left out. Nothing runs and no row is read: the record the action is done to is whatever runs it, so it is never asked for, and a row named by id is checked to be an id. The Ui kit's ActionArgumentField(p, choices, value) is the input each ActionParameterKind takes, for the same editors.

entity Visit {
  DateOnly? On;
  int Hours;
}

/// Promise a visit on a day, for so many hours.
[Action("Promise a visit")]
public void Promise(Visit v, DateOnly on, int hours = 1) {
  v.On = on;
  v.Hours = hours;
  UnitOfWork.Commit();
}

/// What an editor says before it keeps the step — "" when it would run. For "next tuesday":
/// "On is a date, written 2026-09-04 — “next tuesday” is not."
public string BeforeKeeping(string day) {
  var name = Actions.Of(Promise).Name;
  return Actions.Check(name, [new ActionValue { Parameter = "on", Value = day }]);
}

It is a server read: a page calls it through a function of its own, as BeforeKeeping is here.

An action that only reads#

[Action(Query)] declares that running the action changes nothing — it finds something out. The compiler proves it, over everything the action calls, and refuses one that does more:

`[Action(Query)]` on 'Peek': a query only reads, and this one creates Sighting. A query may read any entity and
compute from what it read. It may not create, change or delete a row, raise an event, start a workflow, or reach
outside the platform (an outbound call, `[Sends]`, `[Spends]`) — itself or through anything it calls. Remove `Query`,
or move that work out of it.

ActionInfo.Query is true for one. Whatever runs actions on somebody's behalf can run a query without asking — it is how a search or a lookup is offered to an agent — and must ask, or be allowed, for everything else.

Saying what it sends and spends#

The compiler sees that a function makes an outbound call; it cannot tell a lookup from a mail or a charge. The function that knows says so, once:

[Sends]  void MailReceipt(Order o) { … }        // it sends something to somebody
[Spends] Receipt Charge(Order o) { … }          // it spends money or a metered allowance

Every action that reaches one — however many calls away — reports Effects.Sends or Effects.Spends, and Effects.External with it. The attributes go on any function, most usefully the one a kit wraps its provider in, so an app's actions inherit them without saying anything. A declaration only ever adds an effect: there is no way to declare an action safer than the compiler found it.

What may be done to this record?#

Actions.For(record) is every action whose SUBJECT is the record's own type or a type it derives from — from the app and every package, so a kit's action on a hub's record (Close(WorkItem w)) is offered on a ticket — that the caller may run: an action marked [Authorize(P)] is offered only to a caller P admits. It is what a record's action bar and a command menu list; running one is Actions.Run(a.Name, …) with the record as its subject. On the server, like the rest.

[Principal] entity User { [Required, MaxLength(200)] string Email; security { allow read when IsAuthenticated; } }
[Role] enum AppRole { Member, Boss }
[RoleGrant] entity RoleGrant { [Required] User Grantee; [Required] AppRole Level; security { allow read when IsAuthenticated; } }
policy IsBoss => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Boss);

entity Work {
  [MaxLength(100)] string Title = "";
  security { allow read, create, update when IsAnonymous || IsAuthenticated; }
}
entity Job : Work { }

[Action("Close")] void CloseWork(Work w) { w.Title = w.Title + " (closed)"; }
[Action("Start")] void StartJob(Job j) { j.Title = j.Title + " (started)"; }
[Action("Archive")] [Authorize(IsBoss)] void ArchiveJob(Job j) { j.Title = "archived"; }

/// "Close, Start" for anybody; a boss is offered Archive too.
string Offered(Guid id) {
  var j = Job.Single(x => x.Id == id);
  return string.Join(", ", Actions.For(j).Select(a => a.Label).OrderBy(l => l));
}

Only a marked function runs by name#

Actions.Run("Wipe", …) on a function that is not [Action] answers Gone, exactly as for a name that does not exist — the two are not told apart. A class method, a test, an [AuthMethod] and a client function cannot be marked.

Being an action does not put a verb in your REST API. To offer one to callers outside the app, list it in app.Apis like any function, new Endpoint(CloseJob) { Method = HttpMethod.Post, Path = "/jobs/{id}/close" }, and the path's {id} binds its record. See [[api-rest#action-endpoints]].

What is the caller told when the action throws?#

What the exception's type says. A refusal you raise on purpose reaches the caller with your words; anything else is a failure, and its message — which may be a driver's or a stack's — stays in the app's log.

the action throwsOutcomeProblem
ValidationException, ConflictException, or a class deriving from oneInvalidyour message
NotFoundException, or a class deriving from itGoneyour message
NotAuthorized, or a class deriving from it; a security rule's refusalDeniedthe message
Exception, or a class deriving directly from it; a runtime faultFailedIt failed for a reason the app did not foresee — the app's log has the details.

So a refusal of your own that a caller should hear — a person, a rule, a model running the action as a tool — derives from the type that says what kind of refusal it is ([[function-throw#declare-your-own]]):

/// Told: it is a validation refusal with a name of its own.
public class LimitRefused : ValidationException { }
/// Not told: nothing says what kind of failure this is.
public class Jammed : Exception { }

/// Books a crane for a lift of this many tonnes.
[Action]
string BookCrane(int tonnes) {
  if (tonnes > 40) { throw new LimitRefused("No crane here lifts more than 40 tonnes."); }
  if (tonnes == 13) { throw new Jammed("winch driver: register 0x3F stuck"); }
  return "Booked.";
}

string Book(int tonnes) {
  var result = Actions.Run("BookCrane", [new ActionValue { Parameter = "tonnes", Value = tonnes.ToString() }]);
  return result.Outcome.ToString() + ": " + (result.Outcome == ActionOutcome.Done ? result.Said : result.Problem);
}

[Test]
void a_refusal_deriving_from_a_told_type_reaches_the_caller_in_its_own_words() {
  Assert.Equal("Done: Booked.", Book(10));
  Assert.Equal("Invalid: No crane here lifts more than 40 tonnes.", Book(50));
}

[Test]
void an_exception_that_says_nothing_about_its_kind_is_a_failure_and_its_words_stay_in_the_log() {
  var told = Book(13);
  Assert.True(told.StartsWith("Failed: "));
  Assert.False(told.Contains("0x3F"));
  // …while code that catches it by its own type still reads everything.
  var caught = "";
  try { BookCrane(13); } catch (Jammed e) { caught = e.Message; }
  Assert.Equal("winch driver: register 0x3F stuck", caught);
}

osy lint says so before a caller finds out: correctness-action-throws-a-refusal-its-caller-is-not-told reports a class deriving straight from Exception that an [Action] — or a function a REST Endpoint publishes — can throw, in its own body or in anything it calls, however deep: a function, a class method, and the code of a package you use. It names the class and the function that throws it, at the line of the action that leads there. Jammed in the example is reported; LimitRefused is not, and neither is a throw a catch on the way takes. For a class of your own, derive it from one of the four types above; for a package's, which is not yours to change, catch it where you call the package and throw what your caller should hear — catch (WinchJammed) { throw new ConflictException("That crane is jammed."); }. Where a failure is meant to stay unsaid, [SuppressWarning("correctness-action-throws-a-refusal-its-caller-is-not-told")] on the function records it.

It runs under someone's security, always#

Actions.Run runs the action as the CALLER, under the caller's own rules: a person who could not set the rush by hand cannot set it through Actions.Run either — the result is Denied, with the refusal in Problem. Code with no person behind it (a workflow body, a timer) runs it as the app.

To run an action later as somebody else — a rule a person saved, running when something happens at 3 a.m. — the person consents at the time they choose it: Actions.Consent(action, values) seals the action, the values they chose and who they are, under the app's key. Actions.RunConsented(consent, rest) runs it as them, under their rules as they are THEN: a person who has since lost the role, or whose account is switched off, gets Denied. A consent cannot be edited, names one action, and can only be made by the person it names.

A step written as the action called#

A kit that takes a STEP from the app — an automation rule's "then" — takes it as the action called on its record, t => TagTicket(t, "urgent"), rather than a name and a list of strings. The call is compiled where the app writes it, so a misspelt parameter ('TagTicket' has no parameter 'tga'. Did you mean 'tag'?) or a value of the wrong type is an error there. Actions.NameOf(step) answers which action it is and Actions.ValuesOf(step) what it gives each other parameter, in the text an ActionValue stores — exactly what Actions.Run takes. The values are fixed when the step is written, so the step's SHAPE matters too: it calls an [Action], on the step's own record, and no value reads the record.

Mark the kit's parameter [ActionStep] and the compiler checks that shape at every call, where the step is written:

a step is the action called on the rule's record — `t => TagTicket(t, "urgent")`, and 'JobWords' is not an action — mark it `[Action]` to offer it.
a step is the action called on the rule's record — `t => TagTicket(t, "urgent")`: `label` reads the record, and a rule's values are fixed when it is written. Ask the record in a condition, or let the action read it itself.

Actions.NameOf/ValuesOf refuse the same, saying which, for a step that reaches them some other way (a function value handed on, with no [ActionStep] in between).

Saying what set it off#

cause: is any string the caller wants an action — and everything it sets off — to carry. Inside the action, Actions.Cause reads it. The Automations kit uses it to know a change was made by one of its own rules, and so to stop a rule that would run again on what it just did.

Examples#

A bakery's two actions, and one that is not offered#

[Role] enum BakeryRole { Authenticator, Clerk, Boss }
[RoleGrant] entity RoleGrant { Baker Grantee; [Required] BakeryRole Level; security { allow read when IsAuthenticated; } }
policy IsClerk => RoleGrant.Any(g => g.Grantee == user && (g.Level == BakeryRole.Clerk || g.Level == BakeryRole.Boss));
policy IsBoss => RoleGrant.Any(g => g.Grantee == user && g.Level == BakeryRole.Boss);

[Principal] entity Baker {
  [Unique, MaxLength(200)] string Email;
  security { allow read when IsAuthenticated; }
}

public enum Rush { [Label("Whenever")] Low, [Label("Today")] High }

entity Order {
  [Required, MaxLength(80)] string Item;
  Rush Rush = Rush.Low;
  [MaxLength(40)] string Shelf = "";
  security { allow read, create when IsClerk; allow update when IsBoss; }
}

entity Shelf {
  [Required, MaxLength(40)] string Name;
  security { allow read when IsClerk; }
}

/// Mark how soon an order must go.
[Action("Set the rush")]
public string SetRush(Order o, [Label("How soon")] Rush rush) {
  o.Rush = rush;
  UnitOfWork.Commit();
  return "Rush set to " + rush.Label + ".";
}

/// Put it where it is collected from.
[Action]
public void PutOnShelf(Order o, [Choices(ShelfChoices)] Shelf shelf) {
  o.Shelf = shelf.Name;
  UnitOfWork.Commit();
}

public List<ActionChoice> ShelfChoices() {
  return Shelf.OrderBy(s => s.Name).ToList().Select(s => new ActionChoice { Key = s.Id.ToString(), Label = s.Name }).ToList();
}

/// Not offered — and so never run by name, whoever asks.
public void Wipe(Order o) { o.Delete(); UnitOfWork.Commit(); }

/// What the bakery offers, as a person reads it: "Set the rush (How soon)".
public List<string> Offered() {
  return Actions.Catalog().Select(a => a.Label + " (" + string.Join(", ", a.Parameters.Where(p => !p.Subject).Select(p => p.Label)) + ")").ToList();
}

/// Rush an order, as whoever asks — refused, by sentence, for somebody who may not.
public string RushIt(Guid order) {
  var result = Actions.Run(Actions.Of(SetRush).Name, [
    new ActionValue { Parameter = "o", Value = order.ToString() },
    new ActionValue { Parameter = "rush", Value = Rush.High.Name },
  ]);
  return result.Outcome == ActionOutcome.Done ? result.Said : result.Problem;
}

A lookup and a priced answer, run from a tool call#

public enum Rush { Low, High }

entity Part {
  [Required, MaxLength(80)] string Name;
  decimal Price;
  security { allow read, create when IsAnonymous || IsAuthenticated; }
}

public class Wanted { public string Name = ""; public int Quantity; }

/// What an order would cost.
public class Quote {
  public decimal Total;
  public Rush Rush;
  public List<string> Missing = [];
}

/// The parts whose name contains a word. Only reads — so it may be run without asking.
[Action("Find parts", Query)]
public List<Part> FindParts(string word) {
  return Part.Where(p => p.Name.Contains(word)).OrderBy(p => p.Name).ToList();
}

/// Price a list of parts. A list of a class goes in; a class comes out.
[Action]
public Quote Price(List<Wanted> wanted, Rush rush) {
  var quote = new Quote { Rush = rush };
  foreach (var w in wanted) {
    var found = Part.Where(p => p.Name == w.Name).ToList();
    if (found.Count == 0) { quote.Missing.Add(w.Name); }
    else { quote.Total = quote.Total + found[0].Price * w.Quantity; }
  }
  return quote;
}

/// Whatever takes the money declares it; every action that reaches it reports `Effects.Spends`.
[Spends] void Charge(decimal amount) { }

[Action("Order the parts")]
public string OrderParts(List<Wanted> wanted) {
  var quote = Price(wanted, Rush.Low);
  Charge(quote.Total);
  return "Ordered for " + quote.Total + ".";
}

/// Run an action from one JSON object of arguments, as a tool call holds them, and hand back what it answered:
/// `Call("Price", "{\"wanted\":[{\"Name\":\"bolt\",\"Quantity\":3}],\"rush\":\"High\"}")`.
public string Call(string action, string argumentsJson) {
  var result = Actions.Run(action, Actions.ValuesFromJson(action, argumentsJson));
  return result.Outcome == ActionOutcome.Done ? result.Value : result.Problem;
}

/// The same, typed: the caller holds the Quote the action built.
public decimal TotalFor(string argumentsJson) {
  var result = Actions.Run("Price", Actions.ValuesFromJson("Price", argumentsJson));
  return JsonSerializer.Deserialize<Quote>(result.Value).Total;
}

/// What a model is shown for each action: its name, what it is for, the schema of its arguments — and whether it may
/// be run without asking.
public List<string> Tools() {
  return Actions.Catalog().Select(a =>
    a.Name + (a.Query ? " (reads only)" : a.Effects.Spends ? " (spends)" : "") + ": " + a.Description + " " + a.ParametersSchema).ToList();
}

See also#

Related

function authorization ([Authorize] on a function)

`[Authorize(Policy)]` on a function runs it only for a caller who satisfies the named `policy` — checked when it is…

Json.SchemaOf<T>() — the JSON Schema of a type

The JSON Schema of an Osy# type, as text: a class is an object of its members, an enum is its members by name, a list…

JsonSerializer

Turn a value into a JSON string and a JSON string into a typed object — the C#-faithful System.Text.Json spelling…

The Automations kit — rules that do an app's routine work when something happens

`Osysharp.Automations` runs rules — WHEN something happens to a record (any event about it, or every hour), IF the…

The ReplyComposer kit — where a person writes a reply, on any record

`Osysharp.ReplyComposer` is the box a person writes a reply in — on a ticket, a case, an order, a lead. Saved replies…

security { }

The rules that decide who may read and write an entity's rows. A where clause filters by the row (the owner sees their…