Class: GammaClient

Inherits:
Object
  • Object
show all
Defined in:
app/services/gamma_client.rb

Overview

Faraday-backed client for the Gamma Generate API.

Gamma is an AI-powered presentation/document/webpage creator.
Generations are asynchronous: POST /generations returns a generationId,
which is then polled via GET /generations/:id until status == "completed".

Credentials: stored in Rails credentials under gamma: { api_key: ... }

Defined Under Namespace

Classes: Response

Constant Summary collapse

BASE_URL =

URL for base.

'https://public-api.gamma.app/v1.0'
POLL_INTERVAL_SECONDS =

Polling configuration

4
DEFAULT_TIMEOUT_SECONDS =

Default timeout seconds.

120

Instance Method Summary collapse

Constructor Details

#initialize(api_key: nil) ⇒ GammaClient

Returns a new instance of GammaClient.

Raises:

  • (ArgumentError)


28
29
30
31
32
33
# File 'app/services/gamma_client.rb', line 28

def initialize(api_key: nil)
  @api_key = api_key || Heatwave::Configuration.fetch(:gamma, :api_key)
  raise ArgumentError, 'Gamma API key not configured' if @api_key.blank?

  @connection = build_connection
end

Instance Method Details

#create_and_wait(timeout: DEFAULT_TIMEOUT_SECONDS, **generation_params) ⇒ Response

Submit a generation and poll until it completes or times out.
Suitable for synchronous use inside a RubyLLM tool call.

Parameters:

  • timeout (Integer) (defaults to: DEFAULT_TIMEOUT_SECONDS)

    maximum seconds to wait (default: 120)

  • generation_params (Hash{Symbol => Object})

Returns:

  • (Response)

    with data: { gamma_url:, export_url:, credits: }



316
317
318
319
320
321
# File 'app/services/gamma_client.rb', line 316

def create_and_wait(timeout: DEFAULT_TIMEOUT_SECONDS, **generation_params)
  submit = create_generation(**generation_params)
  return submit unless submit.success?

  poll_until_complete(submit.data[:generation_id], timeout: timeout)
end

#create_from_template(gamma_id:, prompt:, theme_id: nil, folder_ids: nil, export_as: nil, image_options: nil, sharing_options: nil) ⇒ Response

Rework an existing gamma using it as a template.
Creates a NEW gamma based on the original — the original is not modified.

Parameters:

  • gamma_id (String)

    The gammaId of the existing gamma to use as a template.
    Find this in the Gamma app URL (e.g. https://gamma.app/docs/my-deck-abcdef123456)

  • prompt (String)

    Instructions for how to adapt the template. Required.
    E.g. "Rework this pitch deck for a non-technical audience." or
    "Convert this presentation into a formal document with detailed prose."

  • theme_id (String, nil) (defaults to: nil)

    Override the template's theme. Defaults to the template's own theme.

  • folder_ids (Array<String>) (defaults to: nil)

    Folders to save the new gamma into.

  • export_as (String, nil) (defaults to: nil)

    "pdf" or "pptx"

  • image_options (Hash, nil) (defaults to: nil)

    { model:, style: } — only applies when template used AI images

  • sharing_options (Hash, nil) (defaults to: nil)

    { workspaceAccess:, externalAccess: }

Returns:

  • (Response)

    with data: { generation_id: }



259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
# File 'app/services/gamma_client.rb', line 259

def create_from_template(
  gamma_id:,
  prompt:,
  theme_id: nil,
  folder_ids: nil,
  export_as: nil,
  image_options: nil,
  sharing_options: nil
)
  payload = { gammaId: gamma_id, prompt: prompt }
  payload[:themeId]        = theme_id       if theme_id.present?
  payload[:folderIds]      = folder_ids     if folder_ids.present?
  payload[:exportAs]       = export_as      if export_as.present?
  payload[:imageOptions]   = image_options  if image_options.present?
  payload[:sharingOptions] = sharing_options if sharing_options.present?

  Rails.logger.info("[GammaClient] Creating from template gammaId=#{gamma_id}")

  response = @connection.post('generations/from-template') do |req|
    req.body = payload
  end

  if response.success?
    generation_id = response.body['generationId']
    Rails.logger.info("[GammaClient] Template generation submitted: #{generation_id}")
    Response.new(success: true, data: { generation_id: generation_id }, error: nil)
  else
    error = extract_error(response)
    Rails.logger.error("[GammaClient] Create from template failed (#{response.status}): #{error}")
    Response.new(success: false, data: nil, error: error)
  end
rescue Faraday::TimeoutError
  Response.new(success: false, data: nil, error: 'Gamma API request timed out')
rescue Faraday::Error => e
  Rails.logger.error("[GammaClient] Connection error: #{e.message}")
  Response.new(success: false, data: nil, error: "Connection error: #{e.message}")
end

#create_generation(input_text:, text_mode: 'generate', format: 'presentation', theme_id: nil, num_cards: nil, card_split: 'auto', additional_instructions: nil, folder_ids: nil, export_as: nil, text_options: nil, image_options: nil, card_options: nil, sharing_options: nil) ⇒ Response

Submit a new generation request.

Parameters:

  • input_text (String)

    The prompt or content to generate from. Required.

  • text_mode (String) (defaults to: 'generate')

    "generate", "condense", or "preserve"

  • format (String) (defaults to: 'presentation')

    "presentation" (default), "document", "social", "webpage"

  • theme_id (String, nil) (defaults to: nil)

    Theme ID from list_themes (defaults to workspace default)

  • num_cards (Integer, nil) (defaults to: nil)

    Number of slides/cards (1–75)

  • card_split (String) (defaults to: 'auto')

    "auto" (default) or "inputTextBreaks"

  • additional_instructions (String, nil) (defaults to: nil)

    Extra guidance (max 2000 chars)

  • folder_ids (Array<String>) (defaults to: nil)

    Folder IDs to save the gamma into

  • export_as (String, nil) (defaults to: nil)

    "pdf" or "pptx" for a download link in the response

  • text_options (Hash, nil) (defaults to: nil)

    { amount:, tone:, audience:, language: }

  • image_options (Hash, nil) (defaults to: nil)

    { source:, model:, style: }

  • card_options (Hash, nil) (defaults to: nil)

    { dimensions:, headerFooter: }

  • sharing_options (Hash, nil) (defaults to: nil)

    { workspaceAccess:, externalAccess: }

Returns:

  • (Response)

    with data: { generation_id: }



51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
# File 'app/services/gamma_client.rb', line 51

def create_generation(
  input_text:,
  text_mode: 'generate',
  format: 'presentation',
  theme_id: nil,
  num_cards: nil,
  card_split: 'auto',
  additional_instructions: nil,
  folder_ids: nil,
  export_as: nil,
  text_options: nil,
  image_options: nil,
  card_options: nil,
  sharing_options: nil
)
  payload = {
    inputText: input_text,
    textMode: text_mode,
    format: format,
    cardSplit: card_split
  }

  payload[:themeId]               = theme_id if theme_id.present?
  payload[:numCards]              = num_cards if num_cards.present?
  payload[:additionalInstructions] = additional_instructions if additional_instructions.present?
  payload[:folderIds]             = folder_ids              if folder_ids.present?
  payload[:exportAs]              = export_as               if export_as.present?
  payload[:textOptions]           = text_options            if text_options.present?
  payload[:imageOptions]          = image_options           if image_options.present?
  payload[:cardOptions]           = card_options            if card_options.present?
  payload[:sharingOptions]        = sharing_options         if sharing_options.present?

  Rails.logger.info("[GammaClient] Creating generation: format=#{format} textMode=#{text_mode}")

  response = @connection.post('generations') do |req|
    req.body = payload
  end

  if response.success?
    generation_id = response.body['generationId']
    Rails.logger.info("[GammaClient] Generation submitted: #{generation_id}")
    Response.new(success: true, data: { generation_id: generation_id }, error: nil)
  else
    error = extract_error(response)
    Rails.logger.error("[GammaClient] Create failed (#{response.status}): #{error}")
    Response.new(success: false, data: nil, error: error)
  end
rescue Faraday::TimeoutError
  Response.new(success: false, data: nil, error: 'Gamma API request timed out')
rescue Faraday::Error => e
  Rails.logger.error("[GammaClient] Connection error: #{e.message}")
  Response.new(success: false, data: nil, error: "Connection error: #{e.message}")
end

#fetch_pdf_url(gamma_id:) ⇒ Response

Fetch a PDF export URL for any gamma, identified by its gammaId or generationId.

Strategy:

  1. Call GET /generations/id — if the original generation included exportAs: "pdf",
    the export_url is already available.
  2. If not (gamma was created without export, or this is a gammaId), create a PDF
    export via create_from_template with a content-preserving prompt.

Parameters:

  • gamma_id (String)

    gammaId from the Gamma URL, OR a prior generationId

Returns:

  • (Response)

    with data: { pdf_url: }



213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
# File 'app/services/gamma_client.rb', line 213

def fetch_pdf_url(gamma_id:)
  # Step 1: check if a previous generation already has an export URL
  existing = get_generation(gamma_id)
  if existing.success? && existing.data[:export_url].present?
    Rails.logger.info("[GammaClient] Using cached export URL for #{gamma_id}")
    return Response.new(success: true, data: { pdf_url: existing.data[:export_url] }, error: nil)
  end

  # Step 2: generate a new PDF export using the gamma as a template.
  # This only works if the gamma is marked as a "Template" in the Gamma workspace.
  Rails.logger.info("[GammaClient] No cached PDF for #{gamma_id} — attempting PDF export via template")
  result = rework_and_wait(
    gamma_id:  gamma_id,
    prompt:    'Export this as a PDF preserving all content exactly as-is.',
    export_as: 'pdf'
  )

  if result.success? && result.data[:export_url].present?
    Response.new(success: true, data: { pdf_url: result.data[:export_url] }, error: nil)
  elsif result.success?
    Response.new(success: false, data: nil, error: 'PDF export was not returned by Gamma. Try specifying export_as: "pdf" when creating the presentation.')
  elsif result.error.to_s.downcase.include?('access denied') || result.error.to_s.downcase.include?('template')
    Response.new(
      success: false,
      data:    nil,
      error:   'TEMPLATE_ACCESS_DENIED'
    )
  else
    result
  end
end

#get_generation(generation_id) ⇒ Response

Poll for the status of a generation.

Parameters:

  • generation_id (String)

Returns:

  • (Response)

    with data: { status:, gamma_url:, export_url:, credits: }



109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
# File 'app/services/gamma_client.rb', line 109

def get_generation(generation_id)
  response = @connection.get("generations/#{generation_id}")

  if response.success?
    body = response.body
    data = {
      status:     body['status'],
      gamma_url:  body['gammaUrl'],
      export_url: body['exportUrl'],
      credits:    body['credits']
    }.compact
    Response.new(success: true, data: data, error: nil)
  else
    error = extract_error(response)
    Rails.logger.error("[GammaClient] Poll failed (#{response.status}): #{error}")
    Response.new(success: false, data: nil, error: error)
  end
rescue Faraday::TimeoutError
  Response.new(success: false, data: nil, error: 'Gamma API poll timed out')
rescue Faraday::Error => e
  Response.new(success: false, data: nil, error: "Connection error: #{e.message}")
end

#list_folders(query: nil, limit: 50, after: nil) ⇒ Response

Retrieve folders in your workspace.
Supports cursor-based pagination and name search.

Parameters:

  • query (String, nil) (defaults to: nil)

    search by folder name

  • limit (Integer) (defaults to: 50)

    items per page (max 50)

  • after (String, nil) (defaults to: nil)

    cursor from a previous response

Returns:

  • (Response)

    with data: { folders: [{ id:, name: }], has_more:, next_cursor: }



179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
# File 'app/services/gamma_client.rb', line 179

def list_folders(query: nil, limit: 50, after: nil)
  Rails.logger.info('[GammaClient] Listing folders')
  response = @connection.get('folders') do |req|
    req.params['query'] = query if query.present?
    req.params['limit'] = limit
    req.params['after'] = after if after.present?
  end

  if response.success?
    body    = response.body
    folders = Array(body['data']).map { |f| { id: f['id'], name: f['name'] }.compact }
    Response.new(success: true, data: {
      folders:     folders,
      has_more:    body['hasMore'],
      next_cursor: body['nextCursor']
    }, error: nil)
  else
    error = extract_error(response)
    Response.new(success: false, data: nil, error: error)
  end
rescue Faraday::Error => e
  Response.new(success: false, data: nil, error: "Connection error: #{e.message}")
end

#list_themes(query: nil, limit: 50, after: nil) ⇒ Response

Retrieve available themes in your workspace.
Supports cursor-based pagination and name search.

Parameters:

  • query (String, nil) (defaults to: nil)

    search by theme name (case-insensitive)

  • limit (Integer) (defaults to: 50)

    items per page (max 50)

  • after (String, nil) (defaults to: nil)

    cursor from a previous response for next-page fetching

Returns:

  • (Response)

    with data: { themes: [{ id:, name:, type:, color_keywords:, tone_keywords: }], has_more:, next_cursor: }



139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
# File 'app/services/gamma_client.rb', line 139

def list_themes(query: nil, limit: 50, after: nil)
  Rails.logger.info('[GammaClient] Listing themes')
  response = @connection.get('themes') do |req|
    req.params['query'] = query if query.present?
    req.params['limit'] = limit
    req.params['after'] = after if after.present?
  end

  if response.success?
    body   = response.body
    themes = Array(body['data']).map do |t|
      {
        id:             t['id'],
        name:           t['name'],
        type:           t['type'],
        color_keywords: t['colorKeywords'],
        tone_keywords:  t['toneKeywords']
      }.compact
    end
    Response.new(success: true, data: {
      themes:      themes,
      has_more:    body['hasMore'],
      next_cursor: body['nextCursor']
    }, error: nil)
  else
    error = extract_error(response)
    Rails.logger.error("[GammaClient] List themes failed (#{response.status}): #{error}")
    Response.new(success: false, data: nil, error: error)
  end
rescue Faraday::Error => e
  Response.new(success: false, data: nil, error: "Connection error: #{e.message}")
end

#rework_and_wait(timeout: DEFAULT_TIMEOUT_SECONDS, **template_params) ⇒ Response

Rework an existing gamma and wait for the new one to complete.
Wraps create_from_template + polling. Suitable for use inside a tool call.

Parameters:

  • timeout (Integer) (defaults to: DEFAULT_TIMEOUT_SECONDS)

    maximum seconds to wait (default: 120)

  • template_params (Hash{Symbol => Object})

Returns:

  • (Response)

    with data: { gamma_url:, export_url:, credits: }



303
304
305
306
307
308
# File 'app/services/gamma_client.rb', line 303

def rework_and_wait(timeout: DEFAULT_TIMEOUT_SECONDS, **template_params)
  submit = create_from_template(**template_params)
  return submit unless submit.success?

  poll_until_complete(submit.data[:generation_id], timeout: timeout)
end