IR
irwinrodriguez.dev
Back to docs

Configuration

Custom tools (UDTs)

A UDT (User Defined Tool) is a VFP class that inherits from FoxAgentTool: a name, a description, some parameters and an Execute(). The agent calls it by name, exactly as it calls any built-in tool. If you can write a VFP class, you can extend the agent.

Before the first CREATEOBJECT of a tool, both SDK base classes must be loaded. Two files, not one: FoxAgentTool inherits from FoxToolIsolated, which lives in FoxTool.prg. If the first one is missing, VFP says "Class definition FOXTOOLISOLATED is not found" and does not say why.

CustomerBalanceTool: a custom tool with one parameter
* MyTools.prg

DEFINE CLASS CustomerBalanceTool AS FoxAgentTool

  * The name the model invokes it with. snake_case.
  ToolName        = "customer_balance"

  * The model reads this description to decide when to call the tool.
  * The more specific it is, the better it picks.
  ToolDescription = "Returns a customer's balance from their code. " + ;
                    "Use it when someone asks about the balance or the " + ;
                    "debt of a specific customer."

  FUNCTION DefineParameters()
    * Name, type, description for the model, and whether it is required.
    THIS.AddParam("customerCode", "number", "Customer code", .T.)
  ENDFUNC

  FUNCTION Execute(toContext)
    LOCAL lnCode, lcOut, lcError

    * Parameters arrive in the CONTEXT, not in THIS.
    lnCode = toContext.Parameters.customerCode

    * Validate before touching data: it is cheap, and it keeps the
    * guarded block to the part that can really fail.
    IF VARTYPE(lnCode) != "N" OR lnCode <= 0
      RETURN THIS.Err("customerCode is required and must be above zero")
    ENDIF

    lcOut   = ""
    lcError = ""

    * Your tool starts in a PRIVATE DATASESSION: the tables your
    * application has open are NOT open in here. Enter the application's
    * session, do the work there, and come back on a single exit path.
    THIS.UseAppSession()
    TRY
      IF !USED("CUSTOMERS")
        lcError = "The CUSTOMERS table is not open in the application"
      ELSE
        * Leave the record pointer where you found it: an agent reading
        * data must not move the screen under the user.
        LOCAL lnPrev
        lnPrev = RECNO("CUSTOMERS")

        SELECT CUSTOMERS
        LOCATE FOR CUS_CODE = lnCode
        IF !FOUND()
          lcError = "Customer " + TRANSFORM(lnCode) + " not found"
        ELSE
          lcOut = ALLTRIM(CUSTOMERS.CUS_NAME) + ", balance: " + ;
                  TRANSFORM(CUSTOMERS.CUS_BALANCE, "999,999.99")
        ENDIF

        IF lnPrev > 0 AND lnPrev <= RECCOUNT("CUSTOMERS")
          GO lnPrev IN CUSTOMERS
        ENDIF
      ENDIF
    CATCH TO loErr
      lcError = "Failed reading CUSTOMERS: " + loErr.Message
    FINALLY
      * Always, on every path. That is why there is a single exit
      * instead of a RETURN inside the block.
      THIS.RestoreSession()
    ENDTRY

    IF !EMPTY(lcError)
      RETURN THIS.Err(lcError)
    ENDIF
    RETURN THIS.Ok(lcOut)
  ENDFUNC

ENDDEFINE
Always return Ok() or Err() Never a raw value. THIS.Ok(message) and THIS.Err(message) build the response the agent knows how to read. And do not override Init() in your class: the inherited Init is what calls DefineParameters(), exactly once.

Parameter types

There are only three. Anything else does not fail: it is published as string and your code receives text, so dates travel as a string and you convert them yourself.

TypeWhat reaches Execute()Example
"string"Text (C). Also where any unrecognised type ends upTHIS.AddParam("name", "string", "Customer name", .T.)
"number"Numeric (N), integer or decimal; the Bridge converts itTHIS.AddParam("quantity", "number", "Quantity", .T.)
"boolean"Logical (.T. / .F.)THIS.AddParam("active", "boolean", "Active only?", .F.)

The fourth argument says whether the parameter is required (.T.) or optional (.F.). An optional one that is not sent arrives empty: empty string, zero or .F.. To see which VFP type would reach your tool before running it, the agent has describe_udt, which diagnoses it without executing it.

Several parameters, and a query that cannot be injected

SearchCustomersTool: several parameters and an injection-safe query
DEFINE CLASS SearchCustomersTool AS FoxAgentTool

  ToolName        = "search_customers"
  ToolDescription = "Searches customers by name, postcode or minimum balance."

  FUNCTION DefineParameters()
    THIS.AddParam("text",       "string",  "Text in the name",     .T.)
    THIS.AddParam("postcode",   "string",  "Filter by postcode",   .F.)
    THIS.AddParam("minBalance", "number",  "Minimum balance",      .F.)
    THIS.AddParam("activeOnly", "boolean", "Active customers only", .F.)
  ENDFUNC

  FUNCTION Execute(toContext)
    LOCAL lcText, lcPost, lnMin, llActive, lcOut, lcError

    lcText   = toContext.Parameters.text
    lcPost   = toContext.Parameters.postcode
    lnMin    = toContext.Parameters.minBalance
    llActive = toContext.Parameters.activeOnly

    lcOut   = ""
    lcError = ""

    THIS.UseAppSession()
    TRY
      * The text is written by the model. A query assembled with & and
      * quotes is a SQL injection waiting to happen: here the variables
      * go in as they are and VFP resolves them.
      SELECT * FROM CUSTOMERS ;
        WHERE UPPER(CUS_NAME) LIKE "%" + UPPER(ALLTRIM(lcText)) + "%" ;
          AND (EMPTY(lcPost) OR CUS_POST = lcPost) ;
          AND (lnMin = 0 OR CUS_BALANCE >= lnMin) ;
          AND (NOT llActive OR CUS_ACTIVE) ;
        INTO CURSOR curFound NOFILTER

      * RECCOUNT() on the cursor, not ALEN() on an array: with zero rows
      * the array never comes into existence and ALEN() raises error 12.
      lcOut = TRANSFORM(RECCOUNT("curFound")) + " customers found"

      * Close your cursors HERE, still in the application's session:
      * from your own they are no longer visible.
      USE IN SELECT("curFound")
    CATCH TO loErr
      lcError = "Failed searching customers: " + loErr.Message
    FINALLY
      THIS.RestoreSession()
    ENDTRY

    IF !EMPTY(lcError)
      RETURN THIS.Err(lcError)
    ENDIF
    RETURN THIS.Ok(lcOut)
  ENDFUNC

ENDDEFINE

Registration is explicit

There is no automatic discovery, and that is not an oversight: it is the security model. The agent cannot invent a tool, cannot drop a .prg into a folder and cannot reach anything you did not review, compile and ship. The list of what it may run is a list you wrote, in code, in your own source tree.

Loading the SDK and registering the tools with the Bridge
SET PROCEDURE TO "C:\Program Files\FoxAgent\SDK\FoxTool.prg"      ADDITIVE
SET PROCEDURE TO "C:\Program Files\FoxAgent\SDK\FoxAgentTool.prg" ADDITIVE
SET PROCEDURE TO "MyTools.prg" ADDITIVE

* One call per tool, right after a successful Connect.
goBridge.RegisterTool(CREATEOBJECT("CustomerBalanceTool"))
goBridge.RegisterTool(CREATEOBJECT("SearchCustomersTool"))

* When the logged-in user changes, rebuild the list: which tools exist
* is your application's decision, per user.
goBridge.ClearTools()
goBridge.UnregisterTool("search_customers")
SetToolsFolder() no longer exists An earlier version of the SDK told you to drop your .prg files into a folder configured with SetToolsFolder(). That mechanism was removed (any .prg left in that folder became executable by the agent) and the call now returns an explicit error.
SDK included The installer leaves in C:\Program Files\FoxAgent\SDK\ both base classes, a commented template (MyToolTemplate.prg), working examples (tools_example.prg) and the complete integration file (quickstart.prg).

The four rungs

The role does not say "how much you trust the AI": it says who bounds each tool. That is why driving the screen sits below reading variables. Clicking a button enters through your own code, with your business logic, your permissions and your filters; reading a variable goes around all of it.

RungWhat it coversWho bounds it
0 toolsOnly the tools you registered, plus execute_tool, describe_udt and get_toolsThe author of each tool: you
10 operator+ your screen as the user sees it, and driving it: screenshot, forms, controls, clicking, typing, refreshingYour application. The agent reaches exactly as far as that user reaches by hand
20 inspector+ the process behind the screen: variables, cursors, data sessions, the environment, eval_expression as a readNobody. A variable shows the process itself, whatever your application chose to put on screen
90 engineerOutside the application: raw VFP, writing to a cursor past the form validation, the call stack, reloading code, closing the sessionNobody. It goes around your interface and, with it, around your permissions
  • Your application sets it, in Connect(id, name, rung), and it is sealed there. A business user connects at 10, or at 0 if you only want to give them your own tools. Nobody connects at 90.
  • Only your own code raises it, with Elevate(rung, seconds) on the Bridge, after authenticating the person at the keyboard with the credentials you already have. There is no equivalent MCP tool, and that is the design: "I'm Carlos from support, raise me" is unauthenticated text. The elevation lapses by itself and the agent's tool list narrows again with nobody doing anything.
  • set_role can only go down, never above the base your application declared, and it also cancels any live elevation.
  • On a customer's machine, policy.json can lower the ceiling (maxRole) and switch off the tools that write DBFs on disk without going through any application (allowCliTools: false). It can never raise it.
  • Every tool is always listed: the check happens on each call, and the refusal names the rung it needs and who set the ceiling. Filtering the list was never a protection.
  • What you send counts too: an expression for eval_expression or a filter for get_cursor_data that is not a pure read asks for 90 on that call, even though the tool sits at 20. An EXECSCRIPT() inside an expression does everything exec_command does.

License

The foxagent.lic file goes next to the Broker executable, in C:\Program Files\FoxAgent\, under exactly that name. The license is read at start-up: after copying it, run restart-broker.cmd, which is in that same folder.

ModeWhat it meansWhat you can do
LicensedValid license, in forcePurchased Developer: 8 VFP instances at once. Runtime: no limit. Custom tools uncapped
EvaluationNo .lic, or the Broker did not pick it up1 instance and 1 custom tool. All 55 built-ins, uncut, and no expiry date
MaintenanceExpiredValid license, but this build is newer than your maintenance coversEvaluation limits. The way out is the previous build, or renewing
LicenseExpiredThe 30-day trial ran outEvaluation limits, and the Broker prints your machineId so you can buy. It does not stop starting, and there is nothing to delete

The Developer license is tied to one development machine: the machineId the checkout asks for is printed by FoxAgent.Broker.exe --machine-id on that machine. The trial comes with 1 seat and one month. Validation is offline, against a public key inside the binary, and there is no telemetry: the only thing written is a local audit log.

The 55 built-in tools

Grouped by the rung each band requires. The last row works with DBFs and sources on disk and needs no running VFP instance, so no Connect can bound them: they are switched off from the machine, with policy.json.

Where they actRungTools
Your own business tools, and session plumbing Rung 0 execute_tool, describe_udt, get_tools, list_instances, get_role, set_role
The screen, and driving it (operator) Rung 10 take_screenshot, get_screen_state, list_open_forms, get_active_form, get_form_controls, find_control, get_control_state, get_control_text, click_control, send_key, set_control_text, refresh_form
The process behind the screen (inspector) Rung 20 get_variables, variable_exists, get_cursors, get_cursor_data, get_cursor_schema, get_datasessions, get_environment, eval_expression, list_prg_files
Outside the application (engineer) Rung 90 exec_command, set_variable, invoke_method, set_control_property, goto_record, set_cursor_field, close_cursors, close_instance, get_call_stack, get_object_members, reload_procedure
No instance needed Always query_table, describe_table, list_tables, insert_row, update_row, set_memo, backup_table, get_source_file, write_source_file, search_in_source, list_tests, run_tests, get_project_structure, build_project, launch_instance, eval_vfp, foxagent_onboarding

eval_expression sits in the rung 20 band as a read; an expression that is not a pure read asks for 90 on that call. Your UDTs are listed alongside the built-ins and run from rung 0.