agent_gateway 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3517469f438cc1b6f649f04a3efefd74dde4ba8a03fde7ba9453e7f26ec3036d
4
+ data.tar.gz: ba5c6952bdaff79502367cf2463e8bcf6fa2281169b091d831a0ca81b12cb0be
5
+ SHA512:
6
+ metadata.gz: 9c5898a3d51db11fd25a2c159f14a3c856f67da2afa359d0c6c519a366342de2f0fd3f9811d4324281920ee3b2032ad9ac1106fd87a7d78e066725585e4d6fe8
7
+ data.tar.gz: 0577e6232671783e4c0595edb3807e840c95ed68ffa10b2401817068142a60e2e5fc5bf95110f970a30de4a822316e7aa63231697d588bbec1e05f6804efdbec
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jesse Waites
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,137 @@
1
+ ![Agent Gateway](lando.jpeg)
2
+
3
+ # Agent Gateway
4
+
5
+ A mountable Rails engine that exposes a single authenticated JSON endpoint for AI agents to consume your app's data.
6
+
7
+ ## Installation
8
+
9
+ Add to your Gemfile:
10
+
11
+ ```ruby
12
+ gem "agent_gateway"
13
+ ```
14
+
15
+ Then mount the engine in `config/routes.rb`:
16
+
17
+ ```ruby
18
+ mount AgentGateway::Engine => "/agent-gateway"
19
+ ```
20
+
21
+ ## Configuration
22
+
23
+ Create an initializer `config/initializers/agent_gateway.rb`:
24
+
25
+ ```ruby
26
+ AgentGateway.configure do |c|
27
+ c.app_name = "MyApp"
28
+ c.auth_token = ENV["AGENT_GATEWAY_TOKEN"] # required — raises at boot if blank
29
+ c.path_secret = ENV["AGENT_GATEWAY_SECRET"] # auto-generated + logged if omitted
30
+ c.default_period = "7d" # 1d, 7d, 30d, 90d, 1y, all
31
+
32
+ c.expose :users, model: "User" do
33
+ count
34
+ latest 5
35
+ attributes :email, :name, :created_at
36
+ scope :active
37
+ date_column :created_at
38
+ end
39
+
40
+ c.expose :orders, model: "Order" do
41
+ count
42
+ sum :total
43
+ avg :total
44
+ latest 3
45
+ attributes :id, :total, :status, :created_at
46
+ end
47
+ end
48
+ ```
49
+
50
+ ### DSL Reference
51
+
52
+ | Method | Description |
53
+ |--------|-------------|
54
+ | `count` | Include record count |
55
+ | `latest N` | Include N most recent records |
56
+ | `attributes :col1, :col2` | Allowlist fields on latest records |
57
+ | `sum :column` | Sum a numeric column (call multiple times for multiple columns) |
58
+ | `avg :column` | Average a numeric column (call multiple times) |
59
+ | `scope :name` | Apply a named scope to all queries |
60
+ | `date_column :name` | Column used for period filtering (default: `created_at`) |
61
+
62
+ ## Endpoint
63
+
64
+ ```
65
+ GET /<mount_path>/<path_secret>/briefing
66
+ ```
67
+
68
+ ### Authentication
69
+
70
+ Two-layer auth:
71
+ 1. **Path secret** — wrong value returns `404` (endpoint appears nonexistent)
72
+ 2. **Bearer token** — wrong/missing value returns `401`
73
+
74
+ ```bash
75
+ curl -H "Authorization: Bearer $TOKEN" \
76
+ https://myapp.com/agent-gateway/$SECRET/briefing
77
+ ```
78
+
79
+ ### Query Parameters
80
+
81
+ | Param | Description | Example |
82
+ |-------|-------------|---------|
83
+ | `period` | Time window: `1d`, `7d`, `30d`, `90d`, `1y`, `all` | `?period=30d` |
84
+ | `resources` | Comma-separated resource keys to include | `?resources=users,orders` |
85
+ | `latest` | Override latest count for all resources | `?latest=10` |
86
+
87
+ ### Response
88
+
89
+ ```json
90
+ {
91
+ "app_name": "MyApp",
92
+ "generated_at": "2026-02-20T12:00:00Z",
93
+ "period": {
94
+ "from": "2026-02-13",
95
+ "to": "2026-02-20",
96
+ "days": 7
97
+ },
98
+ "data": {
99
+ "users": {
100
+ "count": 142,
101
+ "latest": [
102
+ { "email": "new@example.com", "name": "New User", "created_at": "2026-02-20" }
103
+ ]
104
+ },
105
+ "orders": {
106
+ "count": 89,
107
+ "sum": { "total": 12450.00 },
108
+ "avg": { "total": 139.89 },
109
+ "latest": [
110
+ { "id": 501, "total": "250.00", "status": "paid", "created_at": "2026-02-20" }
111
+ ]
112
+ }
113
+ }
114
+ }
115
+ ```
116
+
117
+ ## Security Notes
118
+
119
+ - `path_secret` is compared using `ActiveSupport::SecurityUtils.secure_compare` (timing-safe)
120
+ - Bearer token is also compared with `secure_compare`
121
+ - If no `path_secret` is configured, one is auto-generated and printed to STDOUT at boot
122
+ - Missing `auth_token` raises at boot — the engine won't start without one
123
+
124
+ ## Using with AI Agents
125
+
126
+ Point your AI agent at the briefing endpoint with the bearer token. The response is a single JSON payload designed for LLM consumption — structured, filterable, and concise.
127
+
128
+ Example system prompt snippet:
129
+
130
+ ```
131
+ Fetch app data from: GET https://myapp.com/agent-gateway/<secret>/briefing
132
+ Header: Authorization: Bearer <token>
133
+ ```
134
+
135
+ ## License
136
+
137
+ MIT
@@ -0,0 +1,74 @@
1
+ module AgentGateway
2
+ class BriefingsController < ActionController::API
3
+ before_action :verify_path_secret
4
+ before_action :authenticate_token
5
+
6
+ def show
7
+ resources = build_resources
8
+ period_info = build_period_info
9
+
10
+ render json: {
11
+ app_name: AgentGateway.configuration.app_name,
12
+ generated_at: Time.current.iso8601,
13
+ period: period_info,
14
+ data: resources
15
+ }
16
+ end
17
+
18
+ private
19
+
20
+ def verify_path_secret
21
+ unless ActiveSupport::SecurityUtils.secure_compare(
22
+ params[:path_secret].to_s,
23
+ AgentGateway.configuration.path_secret.to_s
24
+ )
25
+ head :not_found
26
+ end
27
+ end
28
+
29
+ def authenticate_token
30
+ token = request.headers["Authorization"].to_s.sub(/\ABearer\s+/, "")
31
+ unless ActiveSupport::SecurityUtils.secure_compare(token, AgentGateway.configuration.auth_token.to_s)
32
+ head :unauthorized
33
+ end
34
+ end
35
+
36
+ def build_resources
37
+ configs = AgentGateway.configuration.resources
38
+ requested = params[:resources]&.split(",")&.map(&:strip)&.map(&:to_sym)
39
+
40
+ selected = if requested
41
+ configs.slice(*requested)
42
+ else
43
+ configs
44
+ end
45
+
46
+ selected.each_with_object({}) do |(key, config), hash|
47
+ runner = QueryRunner.new(key, config, period: period_param, latest_override: latest_override)
48
+ hash[key] = runner.call
49
+ end
50
+ end
51
+
52
+ def build_period_info
53
+ days = resolve_display_period
54
+ if days
55
+ { from: days.days.ago.to_date.iso8601, to: Date.current.iso8601, days: days }
56
+ else
57
+ { from: nil, to: Date.current.iso8601, days: "all" }
58
+ end
59
+ end
60
+
61
+ def resolve_display_period
62
+ period_key = period_param || AgentGateway.configuration.default_period
63
+ QueryRunner::PERIODS.fetch(period_key) { QueryRunner::PERIODS.fetch(AgentGateway.configuration.default_period, 7) }
64
+ end
65
+
66
+ def period_param
67
+ params[:period].presence
68
+ end
69
+
70
+ def latest_override
71
+ params[:latest]&.to_i&.then { |n| n > 0 ? n : nil }
72
+ end
73
+ end
74
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,3 @@
1
+ AgentGateway::Engine.routes.draw do
2
+ get ":path_secret/briefing", to: "briefings#show"
3
+ end
data/lando.jpeg ADDED
Binary file
@@ -0,0 +1,35 @@
1
+ module AgentGateway
2
+ class Configuration
3
+ attr_accessor :app_name, :path_secret, :auth_token, :default_period
4
+ attr_reader :resources
5
+
6
+ def initialize
7
+ @app_name = "App"
8
+ @path_secret = nil
9
+ @auth_token = nil
10
+ @default_period = "7d"
11
+ @resources = {}
12
+ end
13
+
14
+ def expose(key, model: nil, &block)
15
+ model_name = model || key.to_s.classify
16
+ config = ResourceConfig.new(model_name)
17
+ config.instance_eval(&block) if block
18
+ @resources[key.to_sym] = config
19
+ end
20
+ end
21
+
22
+ class << self
23
+ def configuration
24
+ @configuration ||= Configuration.new
25
+ end
26
+
27
+ def configure
28
+ yield(configuration)
29
+ end
30
+
31
+ def reset_configuration!
32
+ @configuration = Configuration.new
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,18 @@
1
+ module AgentGateway
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace AgentGateway
4
+
5
+ config.after_initialize do
6
+ cfg = AgentGateway.configuration
7
+
8
+ if cfg.auth_token.nil? || cfg.auth_token.to_s.strip.empty?
9
+ raise "AgentGateway: auth_token must be configured. Set it via AgentGateway.configure { |c| c.auth_token = 'your-token' }"
10
+ end
11
+
12
+ if cfg.path_secret.nil? || cfg.path_secret.to_s.strip.empty?
13
+ cfg.path_secret = SecureRandom.uuid
14
+ puts "[AgentGateway] path_secret auto-generated: #{cfg.path_secret}"
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,81 @@
1
+ module AgentGateway
2
+ class QueryRunner
3
+ PERIODS = {
4
+ "1d" => 1,
5
+ "7d" => 7,
6
+ "30d" => 30,
7
+ "90d" => 90,
8
+ "1y" => 365,
9
+ "all" => nil
10
+ }.freeze
11
+
12
+ def initialize(resource_key, config, period: nil, latest_override: nil)
13
+ @resource_key = resource_key
14
+ @config = config
15
+ @period = period
16
+ @latest_override = latest_override
17
+ end
18
+
19
+ def call
20
+ model = @config.model_name.constantize
21
+ relation = base_relation(model)
22
+
23
+ result = {}
24
+ result[:count] = relation.count if @config.count_enabled?
25
+ result[:sum] = compute_sums(relation) unless @config.sum_columns.empty?
26
+ result[:avg] = compute_avgs(relation) unless @config.avg_columns.empty?
27
+ result[:latest] = fetch_latest(relation) if latest_count
28
+
29
+ result
30
+ rescue NameError
31
+ { error: "Model not found: #{@config.model_name}" }
32
+ end
33
+
34
+ private
35
+
36
+ def base_relation(model)
37
+ relation = @config.scope_name ? model.public_send(@config.scope_name) : model.all
38
+ apply_period_filter(relation)
39
+ end
40
+
41
+ def apply_period_filter(relation)
42
+ days = resolve_period
43
+ return relation unless days
44
+
45
+ date_col = @config.date_column_name
46
+ relation.where(date_col => days.days.ago..)
47
+ end
48
+
49
+ def resolve_period
50
+ period_key = @period || AgentGateway.configuration.default_period
51
+ return nil if period_key == "all"
52
+
53
+ PERIODS.fetch(period_key) { PERIODS.fetch(AgentGateway.configuration.default_period, 7) }
54
+ end
55
+
56
+ def compute_sums(relation)
57
+ @config.sum_columns.each_with_object({}) do |col, hash|
58
+ hash[col] = (relation.sum(col.to_sym) || 0).round(2)
59
+ end
60
+ end
61
+
62
+ def compute_avgs(relation)
63
+ @config.avg_columns.each_with_object({}) do |col, hash|
64
+ hash[col] = (relation.average(col.to_sym) || 0).to_f.round(2)
65
+ end
66
+ end
67
+
68
+ def latest_count
69
+ @latest_override || @config.latest_count
70
+ end
71
+
72
+ def fetch_latest(relation)
73
+ records = relation.order(created_at: :desc).limit(latest_count)
74
+ if @config.attributes_list.any?
75
+ records.map { |r| @config.attributes_list.each_with_object({}) { |a, h| h[a] = r.public_send(a) } }
76
+ else
77
+ records.map { |r| { "id" => r.id } }
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,72 @@
1
+ module AgentGateway
2
+ class ResourceConfig
3
+ attr_reader :model_name
4
+
5
+ def initialize(model_name)
6
+ @model_name = model_name
7
+ @attributes_list = []
8
+ @count_enabled = false
9
+ @latest_count = nil
10
+ @sum_columns = []
11
+ @avg_columns = []
12
+ @scope_name = nil
13
+ @date_column_name = :created_at
14
+ end
15
+
16
+ def attributes(*attrs)
17
+ @attributes_list = attrs.map(&:to_s)
18
+ end
19
+
20
+ def attributes_list
21
+ @attributes_list
22
+ end
23
+
24
+ def count(enabled = true)
25
+ @count_enabled = enabled
26
+ end
27
+
28
+ def count_enabled?
29
+ @count_enabled
30
+ end
31
+
32
+ def latest(n)
33
+ @latest_count = n
34
+ end
35
+
36
+ def latest_count
37
+ @latest_count
38
+ end
39
+
40
+ def sum(column)
41
+ @sum_columns << column.to_s
42
+ end
43
+
44
+ def sum_columns
45
+ @sum_columns
46
+ end
47
+
48
+ def avg(column)
49
+ @avg_columns << column.to_s
50
+ end
51
+
52
+ def avg_columns
53
+ @avg_columns
54
+ end
55
+
56
+ def scope(name)
57
+ @scope_name = name
58
+ end
59
+
60
+ def scope_name
61
+ @scope_name
62
+ end
63
+
64
+ def date_column(name)
65
+ @date_column_name = name.to_sym
66
+ end
67
+
68
+ def date_column_name
69
+ @date_column_name
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,3 @@
1
+ module AgentGateway
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,5 @@
1
+ require "agent_gateway/version"
2
+ require "agent_gateway/resource_config"
3
+ require "agent_gateway/configuration"
4
+ require "agent_gateway/query_runner"
5
+ require "agent_gateway/engine"
metadata ADDED
@@ -0,0 +1,83 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: agent_gateway
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jesse Waites
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-02-20 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: railties
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '7.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activerecord
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '7.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '7.0'
41
+ description: A mountable Rails engine that provides a configured, authenticated JSON
42
+ briefing endpoint for AI agents to consume app data.
43
+ email:
44
+ - jesse@example.com
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - LICENSE
50
+ - README.md
51
+ - app/controllers/agent_gateway/briefings_controller.rb
52
+ - config/routes.rb
53
+ - lando.jpeg
54
+ - lib/agent_gateway.rb
55
+ - lib/agent_gateway/configuration.rb
56
+ - lib/agent_gateway/engine.rb
57
+ - lib/agent_gateway/query_runner.rb
58
+ - lib/agent_gateway/resource_config.rb
59
+ - lib/agent_gateway/version.rb
60
+ homepage: https://github.com/jessewaites/agent_gateway
61
+ licenses:
62
+ - MIT
63
+ metadata: {}
64
+ post_install_message:
65
+ rdoc_options: []
66
+ require_paths:
67
+ - lib
68
+ required_ruby_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '3.0'
73
+ required_rubygems_version: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ - - ">="
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ requirements: []
79
+ rubygems_version: 3.5.16
80
+ signing_key:
81
+ specification_version: 4
82
+ summary: Rails engine exposing app data as a single AI-agent-friendly JSON endpoint
83
+ test_files: []